ewin-reg commited on
Commit
bd3cf61
·
verified ·
1 Parent(s): df923a9

Real Excel test 2: bug fixes in src, new example, text

Browse files
.gitattributes CHANGED
@@ -34,3 +34,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  media/weekly-analyst-demo.mp4 filter=lfs diff=lfs merge=lfs -text
 
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  media/weekly-analyst-demo.mp4 filter=lfs diff=lfs merge=lfs -text
37
+ examples/online-retail-ii-p4.png filter=lfs diff=lfs merge=lfs -text
38
+ examples/online-retail-ii-p6.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -49,7 +49,7 @@ MiniCPM-V 4.7 1B (f16) runs locally through llama.cpp. It selects tools and para
49
  | Real model, quiet week | silent in about 1 s |
50
  | Real model, spike or drop week | PDF draft in about 195 s on a 2-vCPU Colab, about 5 min on the Space; approve re-renders, re-checks and writes it to the sent folder |
51
  | Peak memory | 5.2 GB |
52
- | Test suite (full project) | 656 passed, 2 skipped, 0 failed on 2026-11-12 (658 collected; includes 2 new tests for the number-check fix) |
53
  | First weight edit (16,384 values, no training; superseded by the LoRA), held-out plans, 80 tasks, no grammar | 64/80 to 80/80; a random edit of the same size stayed at 64/80 |
54
  | Small LoRA, round 2 (`lora3`), scored once on held-out seeds 400 to 439 (deployed on the Space) | plans 80/80, drafts 80/80 and 40/40 on each of six variants without a grammar, including two held-out reworded instructions (unedited model: 65/80, 0/80, 0/40 each) |
55
  | First real-data test (UCI Online Retail, about 540,000 invoice rows, T4, model output unconstrained) | valid plan and draft each time; after we fixed a bug in our own number check, a full report for 3 of 3 weeks (it halted on all 3 before the fix) |
@@ -110,6 +110,19 @@ Example output for the week of 2011-12-04 (ISO week 2011-W48), made with `script
110
  |---|---|---|
111
  An earlier try on the Tableau Superstore sample stayed silent because its segments were far below the size minimum.
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  ## Honest limits
114
 
115
  - Detection: recall 0.21 on the holdout replay at the quiet gate (the bar was 0.80), and 14% of quiet weeks raised an alert. We know why, we have not fixed it.
@@ -117,13 +130,13 @@ Example output for the week of 2011-12-04 (ISO week 2011-W48), made with `script
117
  - All scores are on synthetic tasks from one generator, without a JSON grammar, greedy, 80 tasks scored once per round. In production the agent uses a grammar and retries, so the real gain is probably smaller. The first LoRA failed an instruction that did not name the JSON fields (0/40); round 2 fixed that. Dev wordings were written in the same session as the training wordings, so dev was optimistic; the fresh-seed score is the clean number.
118
  - The Space runs the round 2 GGUF; its llama.cpp check used unspent seeds 500 to 511 plus a held-out wording. The real-data test used one dataset and was not run on the Space.
119
  - Free SQL: the unedited model got 21 of 30 under a grammar (bar 24), so named queries are the main path. This was not re-measured with the LoRA.
120
- - Real data: one public dataset (UCI Online Retail). The draft text there is formulaic; the numbers come from checked code. The product reads CSV only, so an Excel sheet must be converted first (`scripts/online_retail_to_csv.py` is one example).
121
  - Chart reading by the vision encoder is not claimed (the vision projector would not load in the runtime used). CPU only on the Space; Apple Silicon speed is untested.
122
  - The scenarios on the Space are synthetic. Nothing is uploaded, and the source file is never modified.
123
 
124
  ## Run the tests
125
 
126
- The full test suite is in `tests/` of the GitHub repo (with `eval/` and `pyproject.toml`): `pip install -e ".[dev]" && pytest`. It uses a stand-in model, not the real one, and takes about 10 minutes on a 12-core machine when split across processes (about 25 minutes in one process). On 2026-11-12: 656 passed, 2 skipped, 0 failed.
127
 
128
  ## Run it
129
 
 
49
  | Real model, quiet week | silent in about 1 s |
50
  | Real model, spike or drop week | PDF draft in about 195 s on a 2-vCPU Colab, about 5 min on the Space; approve re-renders, re-checks and writes it to the sent folder |
51
  | Peak memory | 5.2 GB |
52
+ | Test suite (full project) | 659 passed, 2 skipped, 0 failed on 2026-11-13 (includes 5 new tests for bugs found on real data) |
53
  | First weight edit (16,384 values, no training; superseded by the LoRA), held-out plans, 80 tasks, no grammar | 64/80 to 80/80; a random edit of the same size stayed at 64/80 |
54
  | Small LoRA, round 2 (`lora3`), scored once on held-out seeds 400 to 439 (deployed on the Space) | plans 80/80, drafts 80/80 and 40/40 on each of six variants without a grammar, including two held-out reworded instructions (unedited model: 65/80, 0/80, 0/40 each) |
55
  | First real-data test (UCI Online Retail, about 540,000 invoice rows, T4, model output unconstrained) | valid plan and draft each time; after we fixed a bug in our own number check, a full report for 3 of 3 weeks (it halted on all 3 before the fix) |
 
110
  |---|---|---|
111
  An earlier try on the Tableau Superstore sample stayed silent because its segments were far below the size minimum.
112
 
113
+ ### A second real Excel file (UCI Online Retail II, 2026-11-13)
114
+
115
+ We pointed the app at a second real workbook, the UCI Online Retail II sheet for 2009-2010 (525,461 invoice lines, 53 weeks), on a T4 with `lora3` and unconstrained model calls. It found three bugs of ours, now fixed and covered by tests:
116
+
117
+ - A raw `.xlsx` halted with "file is not valid UTF-8". It now says the file is an Excel workbook and must be exported as CSV first.
118
+ - A first run crashed with `unable to open database file` when the work folder did not exist yet. The store now creates it.
119
+ - Our number check and the model-path calculation looked for a column literally named `date` and ignored `date_column` from the metrics file. All four weeks failed until we fixed it. After the fix, 4 of 4 weeks produced a full report.
120
+
121
+ I recomputed the headline numbers of one report from the CSV and they match the PDF exactly. The draft text is as formulaic as before. Details: [results/real-excel-online-retail-ii.md](results/real-excel-online-retail-ii.md). Example: [report PDF](examples/online-retail-ii-2010-w47.pdf), the [model's replies](examples/online-retail-ii-model-replies.json), the converter `scripts/online_retail_ii_to_csv.py` and the metrics file `examples/metrics_online_retail_ii.yaml`.
122
+
123
+ | ![page 1](examples/online-retail-ii-p1.png) | ![page 2](examples/online-retail-ii-p2.png) | ![page 3](examples/online-retail-ii-p3.png) |
124
+ |---|---|---|
125
+
126
  ## Honest limits
127
 
128
  - Detection: recall 0.21 on the holdout replay at the quiet gate (the bar was 0.80), and 14% of quiet weeks raised an alert. We know why, we have not fixed it.
 
130
  - All scores are on synthetic tasks from one generator, without a JSON grammar, greedy, 80 tasks scored once per round. In production the agent uses a grammar and retries, so the real gain is probably smaller. The first LoRA failed an instruction that did not name the JSON fields (0/40); round 2 fixed that. Dev wordings were written in the same session as the training wordings, so dev was optimistic; the fresh-seed score is the clean number.
131
  - The Space runs the round 2 GGUF; its llama.cpp check used unspent seeds 500 to 511 plus a held-out wording. The real-data test used one dataset and was not run on the Space.
132
  - Free SQL: the unedited model got 21 of 30 under a grammar (bar 24), so named queries are the main path. This was not re-measured with the LoRA.
133
+ - Real data: two public datasets (UCI Online Retail and Online Retail II). The draft text there is formulaic; the numbers come from checked code. The product reads CSV only, so an Excel sheet must be converted first (`scripts/online_retail_to_csv.py` is one example).
134
  - Chart reading by the vision encoder is not claimed (the vision projector would not load in the runtime used). CPU only on the Space; Apple Silicon speed is untested.
135
  - The scenarios on the Space are synthetic. Nothing is uploaded, and the source file is never modified.
136
 
137
  ## Run the tests
138
 
139
+ The full test suite is in `tests/` of the GitHub repo (with `eval/` and `pyproject.toml`): `pip install -e ".[dev]" && pytest`. It uses a stand-in model, not the real one, and takes about 10 minutes on a 12-core machine when split across processes (about 25 minutes in one process). On 2026-11-13: 659 passed, 2 skipped, 0 failed.
140
 
141
  ## Run it
142
 
app.py CHANGED
@@ -272,7 +272,7 @@ Set `WEEKLY_ANALYST_LORA=0` for the older 16,384-value plan edit.
272
  Synthetic tasks from one generator. Reword b and c were never trained on.
273
 
274
  ### Honest limits
275
- - The scenarios on this Space and the reliability figures are synthetic. The one real-data test is described below.
276
  - For free SQL the unedited 1B model got 21 of 30 questions right under a grammar, short of the 24 of 30 bar, so named,
277
  parameterized queries are the main path and free SQL is a last resort. That was measured before the LoRA; free SQL
278
  was not re-measured with the LoRA model this Space runs.
@@ -281,8 +281,10 @@ Synthetic tasks from one generator. Reword b and c were never trained on.
281
  - The change detector's recall on the holdout replay is 0.21 (the bar was 0.80), and 14% of quiet weeks raised an alert.
282
  - The LoRA and weight-edit scores are synthetic, from one task generator, scored once. A first real-data test (the UCI Online
283
  Retail sheet, about 540,000 invoice rows, run on a T4 and not on this Space) gave a valid plan and draft each time, and
284
- after we fixed a bug in our own number check the pipeline produced a full report for 3 of 3 weeks. Nothing has been
285
- measured for general capabilities beyond the agent's task.
 
 
286
  """
287
 
288
  with gr.Blocks(title="Weekly Analyst") as demo_app:
@@ -308,10 +310,10 @@ with gr.Blocks(title="Weekly Analyst") as demo_app:
308
  with gr.Tab("Example reports (already run)"):
309
  gr.Markdown(
310
  "The first two reports (synthetic data) were produced by this same code with the real model and the earlier "
311
- "plan edit, on a 2-core CPU, then approved with the real approve command. The third is real public data "
312
- "(UCI Online Retail, week of 2011-12-04) made with the current LoRA model on a T4; its draft text is formulaic."
313
  )
314
- for prefix, title in (("drop", "Sales drop"), ("spike", "Sales spike"), ("online-retail", "Real data: Online Retail, 2011-W48")):
315
  pages, pdf = example_files(prefix)
316
  gr.Markdown(f"#### {title}")
317
  gr.Gallery(value=pages, label=title, columns=3, height=420, object_fit="contain")
 
272
  Synthetic tasks from one generator. Reword b and c were never trained on.
273
 
274
  ### Honest limits
275
+ - The scenarios on this Space and the reliability figures are synthetic. The real-data tests are described below.
276
  - For free SQL the unedited 1B model got 21 of 30 questions right under a grammar, short of the 24 of 30 bar, so named,
277
  parameterized queries are the main path and free SQL is a last resort. That was measured before the LoRA; free SQL
278
  was not re-measured with the LoRA model this Space runs.
 
281
  - The change detector's recall on the holdout replay is 0.21 (the bar was 0.80), and 14% of quiet weeks raised an alert.
282
  - The LoRA and weight-edit scores are synthetic, from one task generator, scored once. A first real-data test (the UCI Online
283
  Retail sheet, about 540,000 invoice rows, run on a T4 and not on this Space) gave a valid plan and draft each time, and
284
+ after we fixed a bug in our own number check the pipeline produced a full report for 3 of 3 weeks. A second workbook
285
+ (Online Retail II, 525,461 lines) produced a full report for 4 of 4 weeks after we fixed three more bugs of ours: a
286
+ misleading error for a raw .xlsx, a crash when the work folder did not exist, and a hard-coded `date` column name.
287
+ The agent still reads CSV only. Nothing has been measured for general capabilities beyond the agent's task.
288
  """
289
 
290
  with gr.Blocks(title="Weekly Analyst") as demo_app:
 
310
  with gr.Tab("Example reports (already run)"):
311
  gr.Markdown(
312
  "The first two reports (synthetic data) were produced by this same code with the real model and the earlier "
313
+ "plan edit, on a 2-core CPU, then approved with the real approve command. The last two are real public Excel data "
314
+ "(UCI Online Retail, week of 2011-12-04, and Online Retail II, 2010-W47) made with the current LoRA model on a T4; their draft text is formulaic."
315
  )
316
+ for prefix, title in (("drop", "Sales drop"), ("spike", "Sales spike"), ("online-retail", "Real data: Online Retail, 2011-W48"), ("online-retail-ii", "Real data: Online Retail II, 2010-W47")):
317
  pages, pdf = example_files(prefix)
318
  gr.Markdown(f"#### {title}")
319
  gr.Gallery(value=pages, label=title, columns=3, height=420, object_fit="contain")
examples/online-retail-ii-model-replies.json ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "note": "The model's raw replies for the week 2010-W47 (cut 2010-11-28), lora3 GGUF, T4, no JSON schema or grammar. plan = first reply, draft = second.",
3
+ "plan": {
4
+ "steps": [
5
+ {
6
+ "item_key": "return_rate|2010-W47|Country=EIRE,CustomerType=registered",
7
+ "tool": "named_query",
8
+ "args": {
9
+ "name": "by_segment",
10
+ "params": {
11
+ "metric": "return_rate",
12
+ "segment_col": "Country",
13
+ "period": "2010-W47"
14
+ }
15
+ }
16
+ },
17
+ {
18
+ "item_key": "return_rate|2010-W47|Country=Germany,CustomerType=registered",
19
+ "tool": "named_query",
20
+ "args": {
21
+ "name": "by_segment",
22
+ "params": {
23
+ "metric": "return_rate",
24
+ "segment_col": "Country",
25
+ "period": "2010-W47"
26
+ }
27
+ }
28
+ },
29
+ {
30
+ "item_key": "revenue|2010-W47|Country=EIRE,CustomerType=registered",
31
+ "tool": "named_query",
32
+ "args": {
33
+ "name": "by_segment",
34
+ "params": {
35
+ "metric": "revenue",
36
+ "segment_col": "Country",
37
+ "period": "2010-W47"
38
+ }
39
+ }
40
+ }
41
+ ]
42
+ },
43
+ "draft": {
44
+ "headline": "Return rate moved up led by EIRE registered",
45
+ "recommended_action": "Review the affected segments with the owning team",
46
+ "findings": [
47
+ {
48
+ "item_key": "return_rate|2010-W47|Country=EIRE,CustomerType=registered",
49
+ "so_what": "Return rate moved up in EIRE registered",
50
+ "metric_refs": [
51
+ "return_rate"
52
+ ]
53
+ },
54
+ {
55
+ "item_key": "return_rate|2010-W47|Country=Germany,CustomerType=registered",
56
+ "so_what": "Return rate moved down in Germany registered",
57
+ "metric_refs": [
58
+ "return_rate"
59
+ ]
60
+ },
61
+ {
62
+ "item_key": "revenue|2010-W47|Country=EIRE,CustomerType=registered",
63
+ "so_what": "Revenue moved down in EIRE registered",
64
+ "metric_refs": [
65
+ "revenue"
66
+ ]
67
+ }
68
+ ],
69
+ "caveats": []
70
+ }
71
+ }
examples/online-retail-ii-p1.png ADDED
examples/online-retail-ii-p2.png ADDED
examples/online-retail-ii-p3.png ADDED
examples/online-retail-ii-p4.png ADDED

Git LFS Details

  • SHA256: 3e79ade2c1edea6f76feb1d457294189f33fc9781a30a856ce5c93e59521bf42
  • Pointer size: 131 Bytes
  • Size of remote file: 126 kB
examples/online-retail-ii-p5.png ADDED
examples/online-retail-ii-p6.png ADDED

Git LFS Details

  • SHA256: 62f6d8fc8143a0ee2924bce3c081ae83d6979ca59ab71747d7128ca0f518cf41
  • Pointer size: 131 Bytes
  • Size of remote file: 104 kB
examples/online-retail-ii.pdf ADDED
Binary file (55.2 kB). View file
 
src/weekly_analyst/analysis/checks.py CHANGED
@@ -1,87 +1,87 @@
1
- """Deterministic report checks on the rendered PDF text (spec 6.3)."""
2
-
3
- from collections.abc import Sequence
4
- from dataclasses import dataclass
5
- from pathlib import Path
6
- from typing import Literal
7
-
8
- from weekly_analyst.analysis.analyst import wording_check
9
- from weekly_analyst.core.text import numerals
10
- from weekly_analyst.data.metrics_file import MetricDef, MetricsFile
11
- from weekly_analyst.data.recompute import matches_displayed, recompute
12
-
13
-
14
- @dataclass
15
- class ShownNumber:
16
- text: str
17
- metric: str
18
- segment: dict[str, str] | None
19
- period: str
20
- prev_period: str | None = None
21
-
22
-
23
- @dataclass
24
- class Failure:
25
- kind: Literal["wrong_number", "no_metric", "wording", "unlisted_number"]
26
- detail: str
27
-
28
-
29
- def _recomputed(snapshot_path: Path, metric: MetricDef, shown: ShownNumber) -> float | None:
30
- """The value a shown number must match: its level, or the signed change from prev_period to period."""
31
- now = recompute(snapshot_path, metric, shown.segment, shown.period)
32
- if shown.prev_period is None:
33
- return now
34
- before = recompute(snapshot_path, metric, shown.segment, shown.prev_period)
35
- if now is None or before is None:
36
- return None
37
- return now - before
38
-
39
-
40
- def check_report(
41
- pdf_text: str,
42
- shown_numbers: list[ShownNumber],
43
- metrics: MetricsFile,
44
- snapshot_path: Path,
45
- exempt: Sequence[str] = (),
46
- model_text: str = "",
47
- ) -> list[Failure]:
48
- """Check each shown number, the wording, and every numeral on the page.
49
-
50
- exempt holds the numerals the renderer writes itself (dates, page numbers, indices). model_text is
51
- the model-written text: every numeral in it must be a shown number, with no exemption.
52
- """
53
- failures: list[Failure] = []
54
- by_name = {m.name: m for m in metrics.metrics}
55
-
56
- for shown in shown_numbers:
57
- metric = by_name.get(shown.metric)
58
- if metric is None:
59
- failures.append(Failure("no_metric", f"{shown.text!r} names no known metric {shown.metric!r}"))
60
- continue
61
- try:
62
- value = _recomputed(snapshot_path, metric, shown)
63
- matched = value is not None and matches_displayed(value, shown.text)
64
- except ValueError as err:
65
- failures.append(Failure("wrong_number", f"{shown.text!r} cannot be verified: {err}"))
66
- continue
67
- if not matched:
68
- failures.append(
69
- Failure("wrong_number", f"{shown.text!r} not confirmed by recompute (value {value!r})")
70
- )
71
-
72
- for flag in wording_check(pdf_text):
73
- failures.append(Failure("wording", flag))
74
-
75
- listed = {shown.text for shown in shown_numbers}
76
- model_tokens = set(numerals(model_text))
77
- exempt_tokens = set(exempt)
78
- for token in dict.fromkeys(numerals(pdf_text)):
79
- # A numeral from model text is judged by the stricter model_text rule below, so it is not reported twice.
80
- if token not in listed and token not in exempt_tokens and token not in model_tokens:
81
- failures.append(Failure("unlisted_number", token))
82
-
83
- for token in dict.fromkeys(numerals(model_text)):
84
- if token not in listed:
85
- failures.append(Failure("unlisted_number", token))
86
-
87
- return failures
 
1
+ """Deterministic report checks on the rendered PDF text (spec 6.3)."""
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from weekly_analyst.analysis.analyst import wording_check
9
+ from weekly_analyst.core.text import numerals
10
+ from weekly_analyst.data.metrics_file import MetricDef, MetricsFile
11
+ from weekly_analyst.data.recompute import matches_displayed, recompute
12
+
13
+
14
+ @dataclass
15
+ class ShownNumber:
16
+ text: str
17
+ metric: str
18
+ segment: dict[str, str] | None
19
+ period: str
20
+ prev_period: str | None = None
21
+
22
+
23
+ @dataclass
24
+ class Failure:
25
+ kind: Literal["wrong_number", "no_metric", "wording", "unlisted_number"]
26
+ detail: str
27
+
28
+
29
+ def _recomputed(snapshot_path: Path, metric: MetricDef, shown: ShownNumber, date_column: str) -> float | None:
30
+ """The value a shown number must match: its level, or the signed change from prev_period to period."""
31
+ now = recompute(snapshot_path, metric, shown.segment, shown.period, date_column)
32
+ if shown.prev_period is None:
33
+ return now
34
+ before = recompute(snapshot_path, metric, shown.segment, shown.prev_period, date_column)
35
+ if now is None or before is None:
36
+ return None
37
+ return now - before
38
+
39
+
40
+ def check_report(
41
+ pdf_text: str,
42
+ shown_numbers: list[ShownNumber],
43
+ metrics: MetricsFile,
44
+ snapshot_path: Path,
45
+ exempt: Sequence[str] = (),
46
+ model_text: str = "",
47
+ ) -> list[Failure]:
48
+ """Check each shown number, the wording, and every numeral on the page.
49
+
50
+ exempt holds the numerals the renderer writes itself (dates, page numbers, indices). model_text is
51
+ the model-written text: every numeral in it must be a shown number, with no exemption.
52
+ """
53
+ failures: list[Failure] = []
54
+ by_name = {m.name: m for m in metrics.metrics}
55
+
56
+ for shown in shown_numbers:
57
+ metric = by_name.get(shown.metric)
58
+ if metric is None:
59
+ failures.append(Failure("no_metric", f"{shown.text!r} names no known metric {shown.metric!r}"))
60
+ continue
61
+ try:
62
+ value = _recomputed(snapshot_path, metric, shown, metrics.date_column)
63
+ matched = value is not None and matches_displayed(value, shown.text)
64
+ except ValueError as err:
65
+ failures.append(Failure("wrong_number", f"{shown.text!r} cannot be verified: {err}"))
66
+ continue
67
+ if not matched:
68
+ failures.append(
69
+ Failure("wrong_number", f"{shown.text!r} not confirmed by recompute (value {value!r})")
70
+ )
71
+
72
+ for flag in wording_check(pdf_text):
73
+ failures.append(Failure("wording", flag))
74
+
75
+ listed = {shown.text for shown in shown_numbers}
76
+ model_tokens = set(numerals(model_text))
77
+ exempt_tokens = set(exempt)
78
+ for token in dict.fromkeys(numerals(pdf_text)):
79
+ # A numeral from model text is judged by the stricter model_text rule below, so it is not reported twice.
80
+ if token not in listed and token not in exempt_tokens and token not in model_tokens:
81
+ failures.append(Failure("unlisted_number", token))
82
+
83
+ for token in dict.fromkeys(numerals(model_text)):
84
+ if token not in listed:
85
+ failures.append(Failure("unlisted_number", token))
86
+
87
+ return failures
src/weekly_analyst/core/store.py CHANGED
@@ -1,78 +1,79 @@
1
- """SQLite state store: weekly alert counts, reported items, run states (spec 6.6)."""
2
-
3
- import json
4
- import sqlite3
5
- from collections.abc import Iterator
6
- from contextlib import contextmanager
7
- from datetime import datetime
8
- from pathlib import Path
9
-
10
-
11
- def iso_week_of(ts: datetime) -> str:
12
- iso = ts.isocalendar()
13
- return f"{iso.year}-W{iso.week:02d}"
14
-
15
-
16
- _SCHEMA = """
17
- CREATE TABLE IF NOT EXISTS alerts (
18
- item_key TEXT NOT NULL,
19
- iso_week TEXT NOT NULL,
20
- run_id TEXT NOT NULL
21
- );
22
- CREATE TABLE IF NOT EXISTS runs (
23
- run_id TEXT PRIMARY KEY,
24
- state TEXT NOT NULL,
25
- payload TEXT NOT NULL
26
- );
27
- """
28
-
29
-
30
- class StateStore:
31
- def __init__(self, path: Path) -> None:
32
- self._path = Path(path)
33
- with self._transaction() as conn:
34
- conn.executescript(_SCHEMA)
35
-
36
- @contextmanager
37
- def _transaction(self) -> Iterator[sqlite3.Connection]:
38
- conn = sqlite3.connect(self._path)
39
- try:
40
- with conn:
41
- yield conn
42
- finally:
43
- conn.close()
44
-
45
- def alerts_sent_in_week(self, iso_week: str) -> int:
46
- with self._transaction() as conn:
47
- row = conn.execute(
48
- "SELECT COUNT(*) FROM alerts WHERE iso_week = ?", (iso_week,)
49
- ).fetchone()
50
- return int(row[0])
51
-
52
- def record_alert(self, item_key: str, iso_week: str, run_id: str) -> None:
53
- with self._transaction() as conn:
54
- conn.execute(
55
- "INSERT INTO alerts (item_key, iso_week, run_id) VALUES (?, ?, ?)",
56
- (item_key, iso_week, run_id),
57
- )
58
-
59
- def reported_keys(self) -> set[str]:
60
- with self._transaction() as conn:
61
- rows = conn.execute("SELECT DISTINCT item_key FROM alerts").fetchall()
62
- return {row[0] for row in rows}
63
-
64
- def save_state(self, run_id: str, state: str, payload: dict) -> None:
65
- with self._transaction() as conn:
66
- conn.execute(
67
- "INSERT OR REPLACE INTO runs (run_id, state, payload) VALUES (?, ?, ?)",
68
- (run_id, state, json.dumps(payload, sort_keys=True)),
69
- )
70
-
71
- def load_run(self, run_id: str) -> dict | None:
72
- with self._transaction() as conn:
73
- row = conn.execute(
74
- "SELECT state, payload FROM runs WHERE run_id = ?", (run_id,)
75
- ).fetchone()
76
- if row is None:
77
- return None
78
- return {"run_id": run_id, "state": row[0], "payload": json.loads(row[1])}
 
 
1
+ """SQLite state store: weekly alert counts, reported items, run states (spec 6.6)."""
2
+
3
+ import json
4
+ import sqlite3
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+
11
+ def iso_week_of(ts: datetime) -> str:
12
+ iso = ts.isocalendar()
13
+ return f"{iso.year}-W{iso.week:02d}"
14
+
15
+
16
+ _SCHEMA = """
17
+ CREATE TABLE IF NOT EXISTS alerts (
18
+ item_key TEXT NOT NULL,
19
+ iso_week TEXT NOT NULL,
20
+ run_id TEXT NOT NULL
21
+ );
22
+ CREATE TABLE IF NOT EXISTS runs (
23
+ run_id TEXT PRIMARY KEY,
24
+ state TEXT NOT NULL,
25
+ payload TEXT NOT NULL
26
+ );
27
+ """
28
+
29
+
30
+ class StateStore:
31
+ def __init__(self, path: Path) -> None:
32
+ self._path = Path(path)
33
+ self._path.parent.mkdir(parents=True, exist_ok=True) # a first run has no work folder yet
34
+ with self._transaction() as conn:
35
+ conn.executescript(_SCHEMA)
36
+
37
+ @contextmanager
38
+ def _transaction(self) -> Iterator[sqlite3.Connection]:
39
+ conn = sqlite3.connect(self._path)
40
+ try:
41
+ with conn:
42
+ yield conn
43
+ finally:
44
+ conn.close()
45
+
46
+ def alerts_sent_in_week(self, iso_week: str) -> int:
47
+ with self._transaction() as conn:
48
+ row = conn.execute(
49
+ "SELECT COUNT(*) FROM alerts WHERE iso_week = ?", (iso_week,)
50
+ ).fetchone()
51
+ return int(row[0])
52
+
53
+ def record_alert(self, item_key: str, iso_week: str, run_id: str) -> None:
54
+ with self._transaction() as conn:
55
+ conn.execute(
56
+ "INSERT INTO alerts (item_key, iso_week, run_id) VALUES (?, ?, ?)",
57
+ (item_key, iso_week, run_id),
58
+ )
59
+
60
+ def reported_keys(self) -> set[str]:
61
+ with self._transaction() as conn:
62
+ rows = conn.execute("SELECT DISTINCT item_key FROM alerts").fetchall()
63
+ return {row[0] for row in rows}
64
+
65
+ def save_state(self, run_id: str, state: str, payload: dict) -> None:
66
+ with self._transaction() as conn:
67
+ conn.execute(
68
+ "INSERT OR REPLACE INTO runs (run_id, state, payload) VALUES (?, ?, ?)",
69
+ (run_id, state, json.dumps(payload, sort_keys=True)),
70
+ )
71
+
72
+ def load_run(self, run_id: str) -> dict | None:
73
+ with self._transaction() as conn:
74
+ row = conn.execute(
75
+ "SELECT state, payload FROM runs WHERE run_id = ?", (run_id,)
76
+ ).fetchone()
77
+ if row is None:
78
+ return None
79
+ return {"run_id": run_id, "state": row[0], "payload": json.loads(row[1])}
src/weekly_analyst/data/extract.py CHANGED
@@ -1,158 +1,162 @@
1
- """Read-only CSV extract to a Parquet snapshot (spec 6.2 state 2, 6.5)."""
2
-
3
- import codecs
4
- import csv
5
- import hashlib
6
- import logging
7
- import os
8
- import re
9
- import tempfile
10
- from dataclasses import dataclass
11
- from datetime import date
12
- from pathlib import Path
13
-
14
- import polars as pl
15
-
16
- from weekly_analyst.core.config import Settings
17
- from weekly_analyst.data.metrics_file import MetricsFile
18
-
19
- log = logging.getLogger(__name__)
20
-
21
- ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
22
- CHUNK_BYTES = 1 << 20
23
-
24
-
25
- class ExtractError(Exception):
26
- def __init__(self, code: str, detail: str):
27
- super().__init__(f"{code}: {detail}")
28
- self.code = code
29
-
30
-
31
- @dataclass(frozen=True)
32
- class ExtractResult:
33
- snapshot_hash: str
34
- parquet_path: Path
35
- rows_read: int
36
- rows_in_file: int
37
-
38
-
39
- def _halt(code: str, detail: str, rows_read: int, rows_in_file: int | str) -> ExtractError:
40
- log.error(
41
- "extract halted code=%s rows_read=%s rows_in_file=%s detail=%s",
42
- code,
43
- rows_read,
44
- rows_in_file,
45
- detail,
46
- )
47
- return ExtractError(code, detail)
48
-
49
-
50
- def _hash_and_check_utf8(csv_path: Path) -> str:
51
- digest = hashlib.sha256()
52
- decoder = codecs.getincrementaldecoder("utf-8")()
53
- with open(csv_path, "rb") as fh:
54
- for chunk in iter(lambda: fh.read(CHUNK_BYTES), b""):
55
- digest.update(chunk)
56
- try:
57
- decoder.decode(chunk)
58
- except UnicodeDecodeError:
59
- raise _halt("bad_encoding", "file is not valid UTF-8", 0, "unknown") from None
60
- try:
61
- decoder.decode(b"", final=True)
62
- except UnicodeDecodeError:
63
- raise _halt("bad_encoding", "file is not valid UTF-8", 0, "unknown") from None
64
- return digest.hexdigest()
65
-
66
-
67
- def _is_iso_date(value: str) -> bool:
68
- if not ISO_DATE.fullmatch(value):
69
- return False
70
- try:
71
- date.fromisoformat(value)
72
- except ValueError:
73
- return False
74
- return True
75
-
76
-
77
- def _scan_source(csv_path: Path, metrics: MetricsFile) -> tuple[list[str], int, int | None]:
78
- """Stream the file once: check the header, count data rows, find the first bad date."""
79
- with open(csv_path, "r", encoding="utf-8-sig", newline="") as fh:
80
- reader = csv.reader(fh)
81
- header = next(reader, None)
82
- if header is None:
83
- return [], 0, None
84
- if not header:
85
- raise _halt("bad_header", "header row is blank", 0, "unknown")
86
- if any(";" in cell for cell in header):
87
- raise _halt("bad_header", "header uses a semicolon delimiter; use commas", 0, "unknown")
88
- if len(set(header)) != len(header):
89
- raise _halt("duplicate_columns", "header has duplicate column names", 0, "unknown")
90
- if metrics.date_column not in header:
91
- raise _halt("bad_header", f"header has no date column {metrics.date_column!r}", 0, "unknown")
92
- date_index = header.index(metrics.date_column)
93
- ncols = len(header)
94
- rows = 0
95
- first_bad_date: int | None = None
96
- for record in reader:
97
- if len(record) != ncols:
98
- raise _halt(
99
- "bad_header",
100
- f"data row {rows + 1} has {len(record)} fields, header has {ncols}",
101
- 0,
102
- "unknown",
103
- )
104
- rows += 1
105
- if first_bad_date is None and not _is_iso_date(record[date_index]):
106
- first_bad_date = rows
107
- return header, rows, first_bad_date
108
-
109
-
110
- def _write_atomic(df: pl.DataFrame, final: Path) -> None:
111
- fd, tmp_name = tempfile.mkstemp(dir=final.parent, prefix=".extract-", suffix=".tmp")
112
- tmp = Path(tmp_name)
113
- try:
114
- with os.fdopen(fd, "wb") as fh:
115
- df.write_parquet(fh)
116
- fh.flush()
117
- os.fsync(fh.fileno())
118
- os.replace(tmp, final)
119
- except BaseException:
120
- tmp.unlink(missing_ok=True)
121
- raise
122
-
123
-
124
- def extract(csv_path: Path, metrics: MetricsFile, out_dir: Path, settings: Settings) -> ExtractResult:
125
- size = csv_path.stat().st_size
126
- if size > settings.max_bytes:
127
- raise _halt("too_big", f"file is {size} bytes, cap is {settings.max_bytes}", 0, "unknown")
128
-
129
- snapshot_hash = _hash_and_check_utf8(csv_path)
130
- header, rows, first_bad_date = _scan_source(csv_path, metrics)
131
- if rows == 0:
132
- raise _halt("empty", "no data rows after the header", 0, 0)
133
- if rows > settings.max_rows:
134
- raise _halt("too_many_rows", f"{rows} rows, cap is {settings.max_rows}", 0, rows)
135
- cells = rows * len(header)
136
- if cells > settings.max_cells:
137
- raise _halt("too_many_cells", f"{cells} cells, cap is {settings.max_cells}", 0, rows)
138
- if first_bad_date is not None:
139
- raise _halt("bad_date", f"data row {first_bad_date} is not an ISO date (YYYY-MM-DD)", 0, rows)
140
-
141
- metric_columns = metrics.columns()
142
- present = [name for name in header if name in metric_columns]
143
- df = pl.read_csv(
144
- csv_path,
145
- columns=present,
146
- schema_overrides={metrics.date_column: pl.Utf8},
147
- infer_schema_length=None,
148
- )
149
- df = df.with_columns(pl.col(metrics.date_column).str.to_date("%Y-%m-%d", strict=True))
150
-
151
- final = out_dir / f"snapshot-{snapshot_hash}.parquet"
152
- _write_atomic(df, final)
153
- return ExtractResult(
154
- snapshot_hash=snapshot_hash,
155
- parquet_path=final,
156
- rows_read=df.height,
157
- rows_in_file=rows,
158
- )
 
 
 
 
 
1
+ """Read-only CSV extract to a Parquet snapshot (spec 6.2 state 2, 6.5)."""
2
+
3
+ import codecs
4
+ import csv
5
+ import hashlib
6
+ import logging
7
+ import os
8
+ import re
9
+ import tempfile
10
+ from dataclasses import dataclass
11
+ from datetime import date
12
+ from pathlib import Path
13
+
14
+ import polars as pl
15
+
16
+ from weekly_analyst.core.config import Settings
17
+ from weekly_analyst.data.metrics_file import MetricsFile
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
22
+ CHUNK_BYTES = 1 << 20
23
+ WORKBOOK_MAGIC = (b"PK\x03\x04", b"\xd0\xcf\x11\xe0") # .xlsx (zip) and old .xls (OLE)
24
+
25
+
26
+ class ExtractError(Exception):
27
+ def __init__(self, code: str, detail: str):
28
+ super().__init__(f"{code}: {detail}")
29
+ self.code = code
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ExtractResult:
34
+ snapshot_hash: str
35
+ parquet_path: Path
36
+ rows_read: int
37
+ rows_in_file: int
38
+
39
+
40
+ def _halt(code: str, detail: str, rows_read: int, rows_in_file: int | str) -> ExtractError:
41
+ log.error(
42
+ "extract halted code=%s rows_read=%s rows_in_file=%s detail=%s",
43
+ code,
44
+ rows_read,
45
+ rows_in_file,
46
+ detail,
47
+ )
48
+ return ExtractError(code, detail)
49
+
50
+
51
+ def _hash_and_check_utf8(csv_path: Path) -> str:
52
+ digest = hashlib.sha256()
53
+ decoder = codecs.getincrementaldecoder("utf-8")()
54
+ with open(csv_path, "rb") as fh:
55
+ if fh.read(4) in WORKBOOK_MAGIC:
56
+ raise _halt("not_csv", "this is an Excel workbook, not a CSV; export the sheet as CSV (UTF-8) first", 0, "unknown")
57
+ fh.seek(0)
58
+ for chunk in iter(lambda: fh.read(CHUNK_BYTES), b""):
59
+ digest.update(chunk)
60
+ try:
61
+ decoder.decode(chunk)
62
+ except UnicodeDecodeError:
63
+ raise _halt("bad_encoding", "file is not valid UTF-8", 0, "unknown") from None
64
+ try:
65
+ decoder.decode(b"", final=True)
66
+ except UnicodeDecodeError:
67
+ raise _halt("bad_encoding", "file is not valid UTF-8", 0, "unknown") from None
68
+ return digest.hexdigest()
69
+
70
+
71
+ def _is_iso_date(value: str) -> bool:
72
+ if not ISO_DATE.fullmatch(value):
73
+ return False
74
+ try:
75
+ date.fromisoformat(value)
76
+ except ValueError:
77
+ return False
78
+ return True
79
+
80
+
81
+ def _scan_source(csv_path: Path, metrics: MetricsFile) -> tuple[list[str], int, int | None]:
82
+ """Stream the file once: check the header, count data rows, find the first bad date."""
83
+ with open(csv_path, "r", encoding="utf-8-sig", newline="") as fh:
84
+ reader = csv.reader(fh)
85
+ header = next(reader, None)
86
+ if header is None:
87
+ return [], 0, None
88
+ if not header:
89
+ raise _halt("bad_header", "header row is blank", 0, "unknown")
90
+ if any(";" in cell for cell in header):
91
+ raise _halt("bad_header", "header uses a semicolon delimiter; use commas", 0, "unknown")
92
+ if len(set(header)) != len(header):
93
+ raise _halt("duplicate_columns", "header has duplicate column names", 0, "unknown")
94
+ if metrics.date_column not in header:
95
+ raise _halt("bad_header", f"header has no date column {metrics.date_column!r}", 0, "unknown")
96
+ date_index = header.index(metrics.date_column)
97
+ ncols = len(header)
98
+ rows = 0
99
+ first_bad_date: int | None = None
100
+ for record in reader:
101
+ if len(record) != ncols:
102
+ raise _halt(
103
+ "bad_header",
104
+ f"data row {rows + 1} has {len(record)} fields, header has {ncols}",
105
+ 0,
106
+ "unknown",
107
+ )
108
+ rows += 1
109
+ if first_bad_date is None and not _is_iso_date(record[date_index]):
110
+ first_bad_date = rows
111
+ return header, rows, first_bad_date
112
+
113
+
114
+ def _write_atomic(df: pl.DataFrame, final: Path) -> None:
115
+ fd, tmp_name = tempfile.mkstemp(dir=final.parent, prefix=".extract-", suffix=".tmp")
116
+ tmp = Path(tmp_name)
117
+ try:
118
+ with os.fdopen(fd, "wb") as fh:
119
+ df.write_parquet(fh)
120
+ fh.flush()
121
+ os.fsync(fh.fileno())
122
+ os.replace(tmp, final)
123
+ except BaseException:
124
+ tmp.unlink(missing_ok=True)
125
+ raise
126
+
127
+
128
+ def extract(csv_path: Path, metrics: MetricsFile, out_dir: Path, settings: Settings) -> ExtractResult:
129
+ size = csv_path.stat().st_size
130
+ if size > settings.max_bytes:
131
+ raise _halt("too_big", f"file is {size} bytes, cap is {settings.max_bytes}", 0, "unknown")
132
+
133
+ snapshot_hash = _hash_and_check_utf8(csv_path)
134
+ header, rows, first_bad_date = _scan_source(csv_path, metrics)
135
+ if rows == 0:
136
+ raise _halt("empty", "no data rows after the header", 0, 0)
137
+ if rows > settings.max_rows:
138
+ raise _halt("too_many_rows", f"{rows} rows, cap is {settings.max_rows}", 0, rows)
139
+ cells = rows * len(header)
140
+ if cells > settings.max_cells:
141
+ raise _halt("too_many_cells", f"{cells} cells, cap is {settings.max_cells}", 0, rows)
142
+ if first_bad_date is not None:
143
+ raise _halt("bad_date", f"data row {first_bad_date} is not an ISO date (YYYY-MM-DD)", 0, rows)
144
+
145
+ metric_columns = metrics.columns()
146
+ present = [name for name in header if name in metric_columns]
147
+ df = pl.read_csv(
148
+ csv_path,
149
+ columns=present,
150
+ schema_overrides={metrics.date_column: pl.Utf8},
151
+ infer_schema_length=None,
152
+ )
153
+ df = df.with_columns(pl.col(metrics.date_column).str.to_date("%Y-%m-%d", strict=True))
154
+
155
+ final = out_dir / f"snapshot-{snapshot_hash}.parquet"
156
+ _write_atomic(df, final)
157
+ return ExtractResult(
158
+ snapshot_hash=snapshot_hash,
159
+ parquet_path=final,
160
+ rows_read=df.height,
161
+ rows_in_file=rows,
162
+ )
src/weekly_analyst/data/metrics_calc.py CHANGED
@@ -1,38 +1,38 @@
1
- """Model-path metric computation with polars (spec 6.3, 6.8).
2
-
3
- Independent of recompute.py by design: no shared code or engine.
4
- """
5
-
6
- import math
7
-
8
- import polars as pl
9
-
10
- from weekly_analyst.core.config import Settings
11
- from weekly_analyst.data.metrics_file import MetricDef
12
-
13
- DATE_COLUMN = "date"
14
-
15
-
16
- def compute(
17
- df: pl.DataFrame, m: MetricDef, segment: dict[str, str] | None, period: str
18
- ) -> float | None:
19
- """Value of metric m for one ISO week (e.g. "2026-W46") and segment.
20
-
21
- Returns None when the slice has fewer rows than Settings().min_rows_per_segment,
22
- or when there are no non-null values to aggregate.
23
- """
24
- predicate = pl.col(DATE_COLUMN).cast(pl.Date).dt.strftime("%G-W%V") == period
25
- for key, value in (segment or {}).items():
26
- predicate = predicate & (pl.col(key) == value)
27
- rows = df.filter(predicate)
28
- if rows.height < Settings().min_rows_per_segment:
29
- return None
30
- values = rows.get_column(m.column).drop_nulls()
31
- if m.agg == "count":
32
- return float(values.len())
33
- if values.len() == 0:
34
- return None
35
- result = values.sum() if m.agg == "sum" else values.mean()
36
- if result is None or math.isnan(result):
37
- return None
38
- return float(result)
 
1
+ """Model-path metric computation with polars (spec 6.3, 6.8).
2
+
3
+ Independent of recompute.py by design: no shared code or engine.
4
+ """
5
+
6
+ import math
7
+
8
+ import polars as pl
9
+
10
+ from weekly_analyst.core.config import Settings
11
+ from weekly_analyst.data.metrics_file import MetricDef
12
+
13
+ DATE_COLUMN = "date"
14
+
15
+
16
+ def compute(
17
+ df: pl.DataFrame, m: MetricDef, segment: dict[str, str] | None, period: str, date_column: str = DATE_COLUMN
18
+ ) -> float | None:
19
+ """Value of metric m for one ISO week (e.g. "2026-W46") and segment.
20
+
21
+ Returns None when the slice has fewer rows than Settings().min_rows_per_segment,
22
+ or when there are no non-null values to aggregate.
23
+ """
24
+ predicate = pl.col(date_column).cast(pl.Date).dt.strftime("%G-W%V") == period
25
+ for key, value in (segment or {}).items():
26
+ predicate = predicate & (pl.col(key) == value)
27
+ rows = df.filter(predicate)
28
+ if rows.height < Settings().min_rows_per_segment:
29
+ return None
30
+ values = rows.get_column(m.column).drop_nulls()
31
+ if m.agg == "count":
32
+ return float(values.len())
33
+ if values.len() == 0:
34
+ return None
35
+ result = values.sum() if m.agg == "sum" else values.mean()
36
+ if result is None or math.isnan(result):
37
+ return None
38
+ return float(result)
src/weekly_analyst/data/recompute.py CHANGED
@@ -1,78 +1,80 @@
1
- """Independent recompute of a metric from the Parquet snapshot (spec 6.3).
2
-
3
- Deliberately separate from metrics_calc: pyarrow and pyarrow.compute only, so the
4
- two implementations share neither engine nor code.
5
- """
6
-
7
- import math
8
- import re
9
- from decimal import ROUND_HALF_UP, Decimal
10
- from pathlib import Path
11
-
12
- import pyarrow as pa
13
- import pyarrow.compute as pc
14
- import pyarrow.parquet as pq
15
-
16
- from weekly_analyst.core.config import Settings
17
- from weekly_analyst.data.metrics_file import MetricDef
18
-
19
- # The snapshot's date column. recompute's signature carries no date column name,
20
- # and the simulator and metrics fixture both use "date".
21
- DATE_COLUMN = "date"
22
-
23
- _PERIOD = re.compile(r"^(\d{4})-W(\d{2})$")
24
- _SHOWN = re.compile(
25
- r"^(?P<sign>[+-]?)(?P<int>\d{1,3}(?:,\d{3})+|\d+)(?:\.(?P<frac>\d+))?(?P<suffix>[KMB%]?)$"
26
- )
27
- # Divisor that turns the raw value into the unit shown ("%" shows a fraction as percent).
28
- _SCALE = {
29
- "": Decimal(1),
30
- "K": Decimal(10**3),
31
- "M": Decimal(10**6),
32
- "B": Decimal(10**9),
33
- "%": Decimal("0.01"),
34
- }
35
-
36
-
37
- def _parse_period(period: str) -> tuple[int, int]:
38
- match = _PERIOD.match(period)
39
- if match is None:
40
- raise ValueError(f"period must look like 2026-W46, got {period!r}")
41
- return int(match.group(1)), int(match.group(2))
42
-
43
-
44
- def recompute(snapshot_path: Path, m: MetricDef, segment: dict[str, str] | None, period: str) -> float | None:
45
- year, week = _parse_period(period)
46
- segment = segment or {}
47
- table = pq.read_table(snapshot_path, columns=[DATE_COLUMN, m.column, *segment])
48
-
49
- dates = pc.cast(table[DATE_COLUMN], pa.date32())
50
- mask = pc.and_(pc.equal(pc.iso_year(dates), year), pc.equal(pc.iso_week(dates), week))
51
- for key, value in segment.items():
52
- mask = pc.and_(mask, pc.equal(pc.cast(table[key], pa.string()), value))
53
- rows = table.filter(mask, null_selection_behavior="drop")
54
-
55
- if rows.num_rows < Settings().min_rows_per_segment:
56
- return None
57
- values = rows[m.column]
58
- if m.agg == "sum":
59
- result = pc.sum(values)
60
- elif m.agg == "mean":
61
- result = pc.mean(values)
62
- else:
63
- result = pc.count(values)
64
- result = result.as_py()
65
- return None if result is None else float(result)
66
-
67
-
68
- def matches_displayed(value: float, shown: str) -> bool:
69
- match = _SHOWN.match(shown.strip())
70
- if match is None:
71
- raise ValueError(f"unrecognised displayed number: {shown!r}")
72
- if not math.isfinite(value):
73
- return False
74
- frac = match.group("frac") or ""
75
- expected = Decimal(match.group("sign") + match.group("int").replace(",", "") + ("." + frac if frac else ""))
76
- scaled = Decimal(repr(float(value))) / _SCALE[match.group("suffix")]
77
- rounded = scaled.quantize(Decimal(1).scaleb(-len(frac)), rounding=ROUND_HALF_UP)
78
- return rounded == expected
 
 
 
1
+ """Independent recompute of a metric from the Parquet snapshot (spec 6.3).
2
+
3
+ Deliberately separate from metrics_calc: pyarrow and pyarrow.compute only, so the
4
+ two implementations share neither engine nor code.
5
+ """
6
+
7
+ import math
8
+ import re
9
+ from decimal import ROUND_HALF_UP, Decimal
10
+ from pathlib import Path
11
+
12
+ import pyarrow as pa
13
+ import pyarrow.compute as pc
14
+ import pyarrow.parquet as pq
15
+
16
+ from weekly_analyst.core.config import Settings
17
+ from weekly_analyst.data.metrics_file import MetricDef
18
+
19
+ # The snapshot's date column. recompute's signature carries no date column name,
20
+ # and the simulator and metrics fixture both use "date".
21
+ DATE_COLUMN = "date"
22
+
23
+ _PERIOD = re.compile(r"^(\d{4})-W(\d{2})$")
24
+ _SHOWN = re.compile(
25
+ r"^(?P<sign>[+-]?)(?P<int>\d{1,3}(?:,\d{3})+|\d+)(?:\.(?P<frac>\d+))?(?P<suffix>[KMB%]?)$"
26
+ )
27
+ # Divisor that turns the raw value into the unit shown ("%" shows a fraction as percent).
28
+ _SCALE = {
29
+ "": Decimal(1),
30
+ "K": Decimal(10**3),
31
+ "M": Decimal(10**6),
32
+ "B": Decimal(10**9),
33
+ "%": Decimal("0.01"),
34
+ }
35
+
36
+
37
+ def _parse_period(period: str) -> tuple[int, int]:
38
+ match = _PERIOD.match(period)
39
+ if match is None:
40
+ raise ValueError(f"period must look like 2026-W46, got {period!r}")
41
+ return int(match.group(1)), int(match.group(2))
42
+
43
+
44
+ def recompute(
45
+ snapshot_path: Path, m: MetricDef, segment: dict[str, str] | None, period: str, date_column: str = DATE_COLUMN
46
+ ) -> float | None:
47
+ year, week = _parse_period(period)
48
+ segment = segment or {}
49
+ table = pq.read_table(snapshot_path, columns=[date_column, m.column, *segment])
50
+
51
+ dates = pc.cast(table[date_column], pa.date32())
52
+ mask = pc.and_(pc.equal(pc.iso_year(dates), year), pc.equal(pc.iso_week(dates), week))
53
+ for key, value in segment.items():
54
+ mask = pc.and_(mask, pc.equal(pc.cast(table[key], pa.string()), value))
55
+ rows = table.filter(mask, null_selection_behavior="drop")
56
+
57
+ if rows.num_rows < Settings().min_rows_per_segment:
58
+ return None
59
+ values = rows[m.column]
60
+ if m.agg == "sum":
61
+ result = pc.sum(values)
62
+ elif m.agg == "mean":
63
+ result = pc.mean(values)
64
+ else:
65
+ result = pc.count(values)
66
+ result = result.as_py()
67
+ return None if result is None else float(result)
68
+
69
+
70
+ def matches_displayed(value: float, shown: str) -> bool:
71
+ match = _SHOWN.match(shown.strip())
72
+ if match is None:
73
+ raise ValueError(f"unrecognised displayed number: {shown!r}")
74
+ if not math.isfinite(value):
75
+ return False
76
+ frac = match.group("frac") or ""
77
+ expected = Decimal(match.group("sign") + match.group("int").replace(",", "") + ("." + frac if frac else ""))
78
+ scaled = Decimal(repr(float(value))) / _SCALE[match.group("suffix")]
79
+ rounded = scaled.quantize(Decimal(1).scaleb(-len(frac)), rounding=ROUND_HALF_UP)
80
+ return rounded == expected
src/weekly_analyst/systems/detect.py CHANGED
@@ -1,59 +1,59 @@
1
- """Detect state: score and rank candidate changes, then select alerts within the weekly budget (spec 6.2, 6.6)."""
2
-
3
- import math
4
- from dataclasses import replace
5
-
6
- from weekly_analyst.core.components import ItemsC, SnapshotC, ValidationC
7
- from weekly_analyst.core.context import RunContext
8
- from weekly_analyst.core.entity import Entity
9
- from weekly_analyst.core.items import Item
10
- from weekly_analyst.core.system import System
11
- from weekly_analyst.core.world import World
12
- from weekly_analyst.data.metrics_calc import compute
13
- from weekly_analyst.detection import remaining_budget, score_items, select_alerts
14
- from weekly_analyst.systems.common import frame, metric_map, prev_period, split
15
-
16
-
17
- class DetectSystem(System):
18
- name = "detect"
19
- requires = (ValidationC, SnapshotC)
20
- produces = (ItemsC,)
21
-
22
- def run(self, world: World, entity: Entity, ctx: RunContext) -> None:
23
- validation = world.get(entity, ValidationC)
24
- df = frame(world, entity, ctx)
25
- history, current, period = split(df, ctx.metrics.date_column)
26
- selected: list[Item] = []
27
- if not validation.cold_start:
28
- items = score_items(history, current, ctx.metrics, ctx.settings)
29
- prev = prev_period(period)
30
- priced = [
31
- replace(item, detail={**item.detail, "abs_change": _abs_change(ctx, df, item, prev)})
32
- for item in items
33
- ]
34
- min_change = {m.name: m.min_change for m in ctx.metrics.metrics}
35
- budget = remaining_budget(ctx.store, period, ctx.settings)
36
- selected = select_alerts(priced, budget, min_change, ctx.store.reported_keys())
37
- schema_change = any(f["kind"] == "schema_change" for f in validation.findings)
38
- world.add(
39
- entity,
40
- ItemsC(
41
- selected=selected,
42
- material=bool(selected) or schema_change,
43
- alerts_before=ctx.store.alerts_sent_in_week(period),
44
- ),
45
- )
46
-
47
- def ends_run(self, world: World, entity: Entity) -> bool:
48
- """A quiet snapshot (no material item, no schema change) ends after detect; nothing is sent."""
49
- return not world.get(entity, ItemsC).material
50
-
51
-
52
- def _abs_change(ctx: RunContext, df, item: Item, prev: str) -> float:
53
- metric = metric_map(ctx)[item.metric]
54
- segment = dict(item.segment)
55
- now = compute(df, metric, segment, item.period)
56
- before = compute(df, metric, segment, prev)
57
- if now is None or before is None:
58
- return math.nan # a NaN change fails the minimum-change gate
59
- return abs(now - before)
 
1
+ """Detect state: score and rank candidate changes, then select alerts within the weekly budget (spec 6.2, 6.6)."""
2
+
3
+ import math
4
+ from dataclasses import replace
5
+
6
+ from weekly_analyst.core.components import ItemsC, SnapshotC, ValidationC
7
+ from weekly_analyst.core.context import RunContext
8
+ from weekly_analyst.core.entity import Entity
9
+ from weekly_analyst.core.items import Item
10
+ from weekly_analyst.core.system import System
11
+ from weekly_analyst.core.world import World
12
+ from weekly_analyst.data.metrics_calc import compute
13
+ from weekly_analyst.detection import remaining_budget, score_items, select_alerts
14
+ from weekly_analyst.systems.common import frame, metric_map, prev_period, split
15
+
16
+
17
+ class DetectSystem(System):
18
+ name = "detect"
19
+ requires = (ValidationC, SnapshotC)
20
+ produces = (ItemsC,)
21
+
22
+ def run(self, world: World, entity: Entity, ctx: RunContext) -> None:
23
+ validation = world.get(entity, ValidationC)
24
+ df = frame(world, entity, ctx)
25
+ history, current, period = split(df, ctx.metrics.date_column)
26
+ selected: list[Item] = []
27
+ if not validation.cold_start:
28
+ items = score_items(history, current, ctx.metrics, ctx.settings)
29
+ prev = prev_period(period)
30
+ priced = [
31
+ replace(item, detail={**item.detail, "abs_change": _abs_change(ctx, df, item, prev)})
32
+ for item in items
33
+ ]
34
+ min_change = {m.name: m.min_change for m in ctx.metrics.metrics}
35
+ budget = remaining_budget(ctx.store, period, ctx.settings)
36
+ selected = select_alerts(priced, budget, min_change, ctx.store.reported_keys())
37
+ schema_change = any(f["kind"] == "schema_change" for f in validation.findings)
38
+ world.add(
39
+ entity,
40
+ ItemsC(
41
+ selected=selected,
42
+ material=bool(selected) or schema_change,
43
+ alerts_before=ctx.store.alerts_sent_in_week(period),
44
+ ),
45
+ )
46
+
47
+ def ends_run(self, world: World, entity: Entity) -> bool:
48
+ """A quiet snapshot (no material item, no schema change) ends after detect; nothing is sent."""
49
+ return not world.get(entity, ItemsC).material
50
+
51
+
52
+ def _abs_change(ctx: RunContext, df, item: Item, prev: str) -> float:
53
+ metric = metric_map(ctx)[item.metric]
54
+ segment = dict(item.segment)
55
+ now = compute(df, metric, segment, item.period, ctx.metrics.date_column)
56
+ before = compute(df, metric, segment, prev, ctx.metrics.date_column)
57
+ if now is None or before is None:
58
+ return math.nan # a NaN change fails the minimum-change gate
59
+ return abs(now - before)
src/weekly_analyst/systems/draft.py CHANGED
@@ -38,8 +38,8 @@ def _direction(df, ctx: RunContext, item) -> str:
38
  """up, down or unchanged for the item's metric against the prior week: words only, no numbers for the model."""
39
  metric = metric_map(ctx)[item.metric]
40
  segment = dict(item.segment)
41
- now = compute(df, metric, segment, item.period)
42
- before = compute(df, metric, segment, prev_period(item.period))
43
  if now is None or before is None:
44
  return "unknown"
45
  return "up" if now > before else "down" if now < before else "unchanged"
 
38
  """up, down or unchanged for the item's metric against the prior week: words only, no numbers for the model."""
39
  metric = metric_map(ctx)[item.metric]
40
  segment = dict(item.segment)
41
+ now = compute(df, metric, segment, item.period, ctx.metrics.date_column)
42
+ before = compute(df, metric, segment, prev_period(item.period), ctx.metrics.date_column)
43
  if now is None or before is None:
44
  return "unknown"
45
  return "up" if now > before else "down" if now < before else "unchanged"
src/weekly_analyst/systems/report_findings.py CHANGED
@@ -23,7 +23,7 @@ def finding_for(ctx: RunContext, df: pl.DataFrame, item: Item, text) -> tuple[Fi
23
  shown = public_item(item) # what the report prints
24
  label = segment_label(dict(shown.segment))
25
  key = plan_item_key(shown)
26
- level = compute(df, metric, segment, item.period)
27
  if level is None:
28
  raise Halt(f"{key}: too few rows to report")
29
  points = series(ctx, df, metric, segment)
@@ -66,7 +66,7 @@ def attribution_rows(df: pl.DataFrame, date_col: str, metric, col: str, period:
66
  rows = []
67
  for r in attribute_change(week(df, date_col, prev), week(df, date_col, period), metric, col).iter_rows(named=True):
68
  segment = {col: str(r["segment"])}
69
- now, before = compute(df, metric, segment, period), compute(df, metric, segment, prev)
70
  if now is not None and before is not None:
71
  rows.append(AttributionRow(segment=sanitize.label(str(r["segment"])), change=ShownNumber(change_text(now - before), metric.name, segment, period)))
72
  return rows
@@ -76,7 +76,7 @@ def series(ctx: RunContext, df: pl.DataFrame, metric, segment: dict) -> list[tup
76
  periods = df.select(period_expr(ctx.metrics.date_column).unique().sort()).to_series().to_list()
77
  points = []
78
  for period in periods[-SAMPLE_WEEKS:]:
79
- value = compute(df, metric, segment, period)
80
  if value is not None:
81
  points.append((period, "W" + period.split("-W", 1)[1], value))
82
  return points
 
23
  shown = public_item(item) # what the report prints
24
  label = segment_label(dict(shown.segment))
25
  key = plan_item_key(shown)
26
+ level = compute(df, metric, segment, item.period, ctx.metrics.date_column)
27
  if level is None:
28
  raise Halt(f"{key}: too few rows to report")
29
  points = series(ctx, df, metric, segment)
 
66
  rows = []
67
  for r in attribute_change(week(df, date_col, prev), week(df, date_col, period), metric, col).iter_rows(named=True):
68
  segment = {col: str(r["segment"])}
69
+ now, before = compute(df, metric, segment, period, date_col), compute(df, metric, segment, prev, date_col)
70
  if now is not None and before is not None:
71
  rows.append(AttributionRow(segment=sanitize.label(str(r["segment"])), change=ShownNumber(change_text(now - before), metric.name, segment, period)))
72
  return rows
 
76
  periods = df.select(period_expr(ctx.metrics.date_column).unique().sort()).to_series().to_list()
77
  points = []
78
  for period in periods[-SAMPLE_WEEKS:]:
79
+ value = compute(df, metric, segment, period, ctx.metrics.date_column)
80
  if value is not None:
81
  points.append((period, "W" + period.split("-W", 1)[1], value))
82
  return points
src/weekly_analyst/systems/report_model.py CHANGED
@@ -1,94 +1,94 @@
1
- """Report model: the input the fixed PDF template renders, built from the draft and the run's components (spec 6.8)."""
2
-
3
- import polars as pl
4
-
5
- from weekly_analyst.analysis.draft import DraftFields
6
- from weekly_analyst.analysis.plan import _item_key as plan_item_key
7
- from weekly_analyst.core.components import EvidenceC, SourceC, ValidationC
8
- from weekly_analyst.core.context import RunContext
9
- from weekly_analyst.core.entity import Entity
10
- from weekly_analyst.core.items import Item
11
- from weekly_analyst.core.world import World
12
- from weekly_analyst.data.metrics_calc import compute
13
- from weekly_analyst.report.model import DataTable, Finding, Kpi, MetricDefinition, ReportModel, ShownNumber
14
- from weekly_analyst.systems.common import frame, metric_map, prev_period, public_item, raw_items
15
- from weekly_analyst.systems.report_findings import change_text, finding_for, level_text
16
-
17
-
18
- def build_report(world: World, entity: Entity, ctx: RunContext, draft: DraftFields) -> ReportModel:
19
- df = frame(world, entity, ctx)
20
- validation = world.get(entity, ValidationC)
21
- period = validation.period
22
- prev = prev_period(period)
23
- items = raw_items(world, entity)
24
- texts = {f.item_key: f for f in draft.findings}
25
- findings: list[Finding] = []
26
- tables: list[DataTable] = []
27
- for item in items:
28
- found, table = finding_for(ctx, df, item, texts[plan_item_key(public_item(item))])
29
- findings.append(found)
30
- tables.append(table)
31
- snapshot_date = df[ctx.metrics.date_column].max().isoformat()
32
- risks = list(draft.caveats) + [
33
- f"{f['kind'].replace('_', ' ')}: {f['detail']}" for f in validation.findings
34
- ]
35
- failed = [r for r in world.get(entity, EvidenceC).results if "error" in r]
36
- if failed:
37
- risks.append(f"{len(failed)} planned follow-up check(s) could not be run and are not reflected in this report")
38
- if validation.cold_start:
39
- risks.append(
40
- f"insufficient history: fewer than {ctx.settings.cold_start_periods} prior weeks, "
41
- "so no change alerts were sent"
42
- )
43
- next_checks = [f"Check the source rows behind {plan_item_key(public_item(item))}" for item in items]
44
- if any(f["kind"] == "schema_change" for f in validation.findings):
45
- next_checks.append("Confirm the schema change with the owner of the source file")
46
- return ReportModel(
47
- report_name="Weekly analysis",
48
- reporting_period=period,
49
- prior_period=prev,
50
- data_as_of=snapshot_date,
51
- prepared_for=", ".join(ctx.recipients) or "Recipients not set",
52
- report_version="1",
53
- run_id=entity.run_id,
54
- snapshot_date=snapshot_date,
55
- snapshot_hash=world.get(entity, SourceC).snapshot_hash,
56
- headline=draft.headline,
57
- recommended_action=draft.recommended_action,
58
- kpis=_kpis(ctx, df, period, prev, items),
59
- findings=findings,
60
- risks_caveats=risks,
61
- next_checks=next_checks,
62
- metric_definitions=[
63
- MetricDefinition(
64
- name=m.name,
65
- definition=m.definition,
66
- owner=m.owner,
67
- includes=m.inclusions,
68
- excludes=m.exclusions,
69
- version=str(m.version),
70
- checksum=ctx.metrics.checksum,
71
- )
72
- for m in ctx.metrics.metrics
73
- ],
74
- data_tables=tables,
75
- )
76
-
77
-
78
- def _kpis(ctx: RunContext, df: pl.DataFrame, period: str, prev: str, items: list[Item]) -> list[Kpi]:
79
- kpis = []
80
- by_name = metric_map(ctx)
81
- for name in dict.fromkeys(item.metric for item in items):
82
- metric = by_name[name]
83
- now = compute(df, metric, None, period)
84
- before = compute(df, metric, None, prev)
85
- if now is None or before is None:
86
- continue
87
- kpis.append(
88
- Kpi(
89
- label=metric.name,
90
- value=ShownNumber(level_text(now), metric.name, None, period),
91
- vs_prior=ShownNumber(change_text(now - before), metric.name, None, period),
92
- )
93
- )
94
- return kpis
 
1
+ """Report model: the input the fixed PDF template renders, built from the draft and the run's components (spec 6.8)."""
2
+
3
+ import polars as pl
4
+
5
+ from weekly_analyst.analysis.draft import DraftFields
6
+ from weekly_analyst.analysis.plan import _item_key as plan_item_key
7
+ from weekly_analyst.core.components import EvidenceC, SourceC, ValidationC
8
+ from weekly_analyst.core.context import RunContext
9
+ from weekly_analyst.core.entity import Entity
10
+ from weekly_analyst.core.items import Item
11
+ from weekly_analyst.core.world import World
12
+ from weekly_analyst.data.metrics_calc import compute
13
+ from weekly_analyst.report.model import DataTable, Finding, Kpi, MetricDefinition, ReportModel, ShownNumber
14
+ from weekly_analyst.systems.common import frame, metric_map, prev_period, public_item, raw_items
15
+ from weekly_analyst.systems.report_findings import change_text, finding_for, level_text
16
+
17
+
18
+ def build_report(world: World, entity: Entity, ctx: RunContext, draft: DraftFields) -> ReportModel:
19
+ df = frame(world, entity, ctx)
20
+ validation = world.get(entity, ValidationC)
21
+ period = validation.period
22
+ prev = prev_period(period)
23
+ items = raw_items(world, entity)
24
+ texts = {f.item_key: f for f in draft.findings}
25
+ findings: list[Finding] = []
26
+ tables: list[DataTable] = []
27
+ for item in items:
28
+ found, table = finding_for(ctx, df, item, texts[plan_item_key(public_item(item))])
29
+ findings.append(found)
30
+ tables.append(table)
31
+ snapshot_date = df[ctx.metrics.date_column].max().isoformat()
32
+ risks = list(draft.caveats) + [
33
+ f"{f['kind'].replace('_', ' ')}: {f['detail']}" for f in validation.findings
34
+ ]
35
+ failed = [r for r in world.get(entity, EvidenceC).results if "error" in r]
36
+ if failed:
37
+ risks.append(f"{len(failed)} planned follow-up check(s) could not be run and are not reflected in this report")
38
+ if validation.cold_start:
39
+ risks.append(
40
+ f"insufficient history: fewer than {ctx.settings.cold_start_periods} prior weeks, "
41
+ "so no change alerts were sent"
42
+ )
43
+ next_checks = [f"Check the source rows behind {plan_item_key(public_item(item))}" for item in items]
44
+ if any(f["kind"] == "schema_change" for f in validation.findings):
45
+ next_checks.append("Confirm the schema change with the owner of the source file")
46
+ return ReportModel(
47
+ report_name="Weekly analysis",
48
+ reporting_period=period,
49
+ prior_period=prev,
50
+ data_as_of=snapshot_date,
51
+ prepared_for=", ".join(ctx.recipients) or "Recipients not set",
52
+ report_version="1",
53
+ run_id=entity.run_id,
54
+ snapshot_date=snapshot_date,
55
+ snapshot_hash=world.get(entity, SourceC).snapshot_hash,
56
+ headline=draft.headline,
57
+ recommended_action=draft.recommended_action,
58
+ kpis=_kpis(ctx, df, period, prev, items),
59
+ findings=findings,
60
+ risks_caveats=risks,
61
+ next_checks=next_checks,
62
+ metric_definitions=[
63
+ MetricDefinition(
64
+ name=m.name,
65
+ definition=m.definition,
66
+ owner=m.owner,
67
+ includes=m.inclusions,
68
+ excludes=m.exclusions,
69
+ version=str(m.version),
70
+ checksum=ctx.metrics.checksum,
71
+ )
72
+ for m in ctx.metrics.metrics
73
+ ],
74
+ data_tables=tables,
75
+ )
76
+
77
+
78
+ def _kpis(ctx: RunContext, df: pl.DataFrame, period: str, prev: str, items: list[Item]) -> list[Kpi]:
79
+ kpis = []
80
+ by_name = metric_map(ctx)
81
+ for name in dict.fromkeys(item.metric for item in items):
82
+ metric = by_name[name]
83
+ now = compute(df, metric, None, period, ctx.metrics.date_column)
84
+ before = compute(df, metric, None, prev, ctx.metrics.date_column)
85
+ if now is None or before is None:
86
+ continue
87
+ kpis.append(
88
+ Kpi(
89
+ label=metric.name,
90
+ value=ShownNumber(level_text(now), metric.name, None, period),
91
+ vs_prior=ShownNumber(change_text(now - before), metric.name, None, period),
92
+ )
93
+ )
94
+ return kpis