-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathreconcile.py
More file actions
2512 lines (2252 loc) · 132 KB
/
Copy pathreconcile.py
File metadata and controls
2512 lines (2252 loc) · 132 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Reconciliation helper for the agami-reconcile skill.
The skill drives the LLM (question generation, query execution, narration);
this helper handles the deterministic parts:
- CSV parsing (header / no-header, common dialects)
- Number-string parsing ($4.2M, ₹2.16Cr, "47,238,221.00", "42%", etc.)
- Diff logic with tolerance
- The tolerance band around an observed number, in a golden item's own bounds keys
Stdlib only.
Usage:
# Parse a CSV and emit a normalized JSON list of {label, expected_value}:
python3 reconcile.py parse --csv /path/to/dashboard-export.csv
# Diff two numbers (expected vs actual) with optional tolerance:
python3 reconcile.py diff --expected 47238221 --actual 47200000 --tolerance 0.01
# (--tolerance also takes a percentage: `--tolerance 1%` is the same band)
# Band an observed number, ready to paste as a golden item's `bounds`:
python3 reconcile.py band --value 47238221 --tolerance 0.01
# Read any of the four input shapes a person brings into evidence rows:
# (a) questions, (b) questions with the SQL they trust, (c) labels with numbers,
# (d) labels with numbers and the SQL behind each. Several files merge by label.
# A second file merges by label, so it needs a label column: `label,sql` reads as the SQL
# behind each tile; a bare .sql file has no labels and stands as its own rows.
python3 reconcile.py intake --file tiles.csv --file sql.csv --source "the finance dashboard"
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
from pathlib import Path
from typing import Any
# --- Number parsing -------------------------------------------------------
# Currency symbols and their codes that we strip from the front of a number.
CURRENCY_SYMBOLS = ("$", "€", "£", "¥", "₹", "₩", "₽", "₿")
# Magnitude suffixes. Indian numbering (Lakh / Crore) is included because
# dashboards from Indian deployments commonly use it.
SUFFIXES: dict[str, float] = {
"k": 1_000,
"K": 1_000,
"m": 1_000_000,
"M": 1_000_000,
"b": 1_000_000_000,
"B": 1_000_000_000,
"bn": 1_000_000_000,
"Bn": 1_000_000_000,
"BN": 1_000_000_000,
"L": 100_000, # Lakh
"l": 100_000,
"Cr": 10_000_000, # Crore
"cr": 10_000_000,
"CR": 10_000_000,
}
def parse_value(s: Any) -> float | None:
"""Parse a string-or-number into a float. Returns None if uninterpretable.
Handles:
"47238221" -> 47238221.0
"47,238,221.00" -> 47238221.0
"$4.2M" -> 4200000.0
"₹2.16Cr" -> 21600000.0
"42%" -> 0.42 (percent → fraction)
"12.4%" -> 0.124
" 148.95 " -> 148.95
"(123.45)" -> -123.45 (accounting-style negative)
"n/a", "—", "" -> None
None -> None
"""
if s is None:
return None
if isinstance(s, (int, float)) and not isinstance(s, bool):
return float(s)
if not isinstance(s, str):
return None
raw = s.strip()
if not raw:
return None
# Common null sentinels in dashboards.
if raw.lower() in {"n/a", "na", "—", "-", "null", "none", "nil"}:
return None
is_percent = raw.endswith("%")
if is_percent:
raw = raw[:-1].strip()
# Accounting parens for negatives: (123.45) → -123.45
is_negative = False
if raw.startswith("(") and raw.endswith(")"):
is_negative = True
raw = raw[1:-1].strip()
# Strip leading currency symbols + ISO codes (USD / INR / EUR / etc.).
for sym in CURRENCY_SYMBOLS:
if raw.startswith(sym):
raw = raw[len(sym) :].strip()
break
iso = re.match(r"^[A-Z]{3}\s+", raw)
if iso:
raw = raw[iso.end() :].strip()
# Look for a magnitude suffix (longest-match: handle "Cr" before "C").
suffix_multiplier = 1.0
sorted_suffixes = sorted(SUFFIXES.keys(), key=len, reverse=True)
for suf in sorted_suffixes:
if raw.endswith(suf) and len(raw) > len(suf):
head = raw[: -len(suf)].strip()
# Only treat as a suffix if what's before is a clean number.
if (
re.fullmatch(r"-?\d+(\.\d+)?(\s*[,\s]\s*\d{3})*", head)
or re.fullmatch(r"-?\d+(?:[,_]\d{3})*(?:\.\d+)?", head)
or re.fullmatch(r"-?\d+(?:\.\d+)?", head)
):
raw = head
suffix_multiplier = SUFFIXES[suf]
break
# Strip thousands separators (commas, underscores, NBSP, regular spaces).
raw = re.sub(r"[,_ ]", "", raw)
raw = raw.replace(" ", "")
try:
n = float(raw)
except ValueError:
return None
n *= suffix_multiplier
if is_negative:
n = -n
if is_percent:
n /= 100.0
return n
# --- CSV parsing ----------------------------------------------------------
# Heuristic header detection. The first cell is the label and is *always*
# non-numeric (the metric name). The second cell is the value: if it parses
# as a number, the row is data; if not, the row is a header (with column names
# like "Value" or "Amount").
def _looks_like_header(row: list[str]) -> bool:
if not row or len(row) < 2:
return False
return parse_value(row[1]) is None
def parse_csv(path: str) -> list[dict]:
"""Parse a reconciliation CSV. Returns list of {label, expected_value, raw_value}.
Accepts:
- 2 columns (label, value) — most common
- 3+ columns: first is label, second is value, the rest are appended to
label as `(extra1, extra2)` for context.
- With or without a header row (auto-detected).
Skips rows where the value can't be parsed as a number; emits them with
`expected_value: null` so the SKILL can surface them to the user.
"""
rows: list[dict] = []
p = Path(path).expanduser()
if not p.exists():
raise FileNotFoundError(f"CSV not found: {p}")
with p.open(newline="") as f:
reader = csv.reader(f)
all_rows = [r for r in reader if r and any(c.strip() for c in r)]
if not all_rows:
return rows
# Header detection on the first non-empty row.
has_header = _looks_like_header(all_rows[0])
data_rows = all_rows[1:] if has_header else all_rows
for r in data_rows:
if len(r) < 2:
continue
label = r[0].strip()
raw_value = r[1].strip()
extras = [c.strip() for c in r[2:] if c.strip()]
if extras:
label = f"{label} ({', '.join(extras)})"
rows.append(
{
"label": label,
"expected_value": parse_value(raw_value),
"raw_value": raw_value,
}
)
return rows
# --- Diff -----------------------------------------------------------------
def diff(
expected: float | None,
actual: float | None,
*,
tolerance: float = 0.01,
) -> dict:
"""Compare expected vs actual. `tolerance` is fractional (0.01 = ±1%).
Returns:
{
"match": bool,
"delta": float (actual - expected) or None,
"delta_pct": float ((actual - expected) / expected) or None,
"reason": "match" | "mismatch" | "missing_expected" | "missing_actual"
}
"""
if expected is None:
return {"match": False, "delta": None, "delta_pct": None, "reason": "missing_expected"}
if actual is None:
return {"match": False, "delta": None, "delta_pct": None, "reason": "missing_actual"}
delta = actual - expected
delta_pct: float | None = None
if expected != 0:
delta_pct = delta / expected
match = abs(delta_pct) <= tolerance
else:
# Expected is exactly 0 — exact match required (no relative tolerance possible).
match = actual == 0
return {
"match": match,
"delta": delta,
"delta_pct": delta_pct,
"reason": "match" if match else "mismatch",
}
# --- Band -----------------------------------------------------------------
def band(value: float, *, tolerance: float = 0.01) -> dict:
"""The band a single observed number is allowed to land in, as `GoldenBounds` keys.
`tolerance` is fractional (0.01 = ±1%), the same shape and default `diff` uses. The four
keys are exactly the ones `semantic_model.golden.GoldenBounds` accepts, so a caller pastes
the output into an item and does no arithmetic of its own.
Returns:
{"min_rows": 1, "max_rows": 1, "min_value": <float>, "max_value": <float>}
"""
low, high = value * (1 - tolerance), value * (1 + tolerance)
# A negative observation inverts the two — and GoldenBounds refuses a floor above its
# ceiling, so the band would be rejected rather than merely read oddly. A zero value falls
# out of this as a zero band, which matches diff's rule that a zero expected is only ever
# matched exactly: there is no relative tolerance around nothing.
return {
"min_rows": 1,
"max_rows": 1,
"min_value": min(low, high),
"max_value": max(low, high),
}
# --- Intake ---------------------------------------------------------------
#
# Any input a person brings reduces to rows of three optional fields: the question, the
# statement, the expected number. The four shapes the skill names are subsets of that row:
# (a) questions only (b) questions with the SQL the person trusts
# (c) labels with numbers (d) labels with numbers and the SQL behind each tile
# `intake` reads all four and says which it saw. The number path is untouched: a two-column
# CSV still goes through `parse_csv`, and a third column that is not SQL is still glued onto
# the label as context, exactly as `parse` always did.
_STATEMENT_RE = re.compile(r"^\s*(with|select)\b", re.IGNORECASE)
# Header names a person is likely to type, folded to the field each stands for. A header is
# recognised by NAME rather than by position so a `question,sql` file and a `label,value,sql` file
# both read the way they were written.
_HEADER_FIELDS: dict[str, str] = {
"label": "label", "metric": "label", "tile": "label", "name": "label", "kpi": "label",
"value": "value", "expected": "value", "expected_value": "value", "number": "value",
"amount": "value", "actual": "value",
"sql": "statement", "statement": "statement", "query": "statement",
"question": "question", "prompt": "question",
}
def _fold(text: str) -> str:
"""Case and whitespace fold, the only normalization a label match is allowed."""
return re.sub(r"\s+", " ", text.strip()).lower()
def _is_statement(cell: str | None) -> bool:
return bool(cell) and _STATEMENT_RE.match(cell) is not None
def _row_shape(row: dict) -> str:
has_statement = row["statement"] is not None
has_expected = row["expected"] is not None
if has_statement and has_expected:
return "d"
if has_statement:
return "b"
if has_expected:
return "c"
return "a"
def _new_row(*, file: str, line: int, source: str | None, label: str | None = None,
question: str | None = None, statement: str | None = None,
raw_value: str | None = None) -> dict:
row = {
"label": label or None,
"question": question or None,
"statement": statement.strip().rstrip(";").strip() if statement else None,
"expected": parse_value(raw_value) if raw_value is not None else None,
"raw_value": raw_value if raw_value not in (None, "") else None,
"provenance": {"shape": None, "source": source, "file": file, "line": line, "graded": None},
}
row["provenance"]["shape"] = _row_shape(row)
return row
def _header_map(first: list[str], rest: list[list[str]]) -> dict[int, str] | None:
"""Which field each column holds, when the first row is a header; None when it is data.
Two ways a row is a header. Every cell names a field this module knows, which is how a
`question,sql` or `label,value,sql` file declares itself. Or, the legacy two-column case
`parse_csv` has always handled: a second cell that is neither a number nor a statement, over a
file whose later rows do carry numbers there.
"""
cells = [c.strip() for c in first]
if cells and all(_fold(c) in _HEADER_FIELDS for c in cells if c):
return {i: _HEADER_FIELDS[_fold(c)] for i, c in enumerate(cells) if c}
if (len(cells) >= 2 and parse_value(cells[1]) is None and not _is_statement(cells[1])
and any(len(r) >= 2 and parse_value(r[1]) is not None for r in rest)):
fields = {0: "label", 1: "value"}
for i in range(2, len(cells)):
fields[i] = "statement" if _fold(cells[i]) in ("sql", "statement", "query") else "extra"
return fields
return None
def _row_from_named(cells: list[str], fields: dict[int, str], *, file: str, line: int,
source: str | None) -> tuple[dict | None, str | None]:
got: dict[str, str] = {}
extras: list[str] = []
for i, cell in enumerate(cells):
cell = cell.strip()
if not cell:
continue
field = fields.get(i, "extra")
if field == "extra":
extras.append(cell)
elif field == "statement" and not _is_statement(cell):
# A `sql` column holding something that is not a statement is context, not SQL.
extras.append(cell)
else:
got[field] = cell
label = got.get("label")
if label and extras:
label = f"{label} ({', '.join(extras)})"
raw = got.get("value")
if raw is not None and parse_value(raw) is None:
return None, f"the value {raw!r} could not be read as a number"
if not any(k in got for k in ("label", "question", "statement", "value")):
return None, "no question, statement or number in the row"
return _new_row(file=file, line=line, source=source, label=label,
question=got.get("question"), statement=got.get("statement"),
raw_value=raw), None
def _row_from_positional(cells: list[str], *, file: str, line: int,
source: str | None) -> tuple[dict | None, str | None]:
"""A data row with no header to name its columns, read by shape."""
cells = [c.strip() for c in cells]
if len(cells) == 1:
text = cells[0]
if _is_statement(text):
return _new_row(file=file, line=line, source=source, statement=text), None
return _new_row(file=file, line=line, source=source, question=text), None
first, second, rest = cells[0], cells[1], cells[2:]
if _is_statement(second):
return _new_row(file=file, line=line, source=source, question=first, statement=second), None
if parse_value(second) is None:
return None, f"the second column {second!r} is neither a number nor a statement"
statement = None
extras = []
for cell in rest:
if _is_statement(cell) and statement is None:
statement = cell
elif cell:
extras.append(cell)
label = f"{first} ({', '.join(extras)})" if extras else first
return _new_row(file=file, line=line, source=source, label=label, statement=statement,
raw_value=second), None
def _rows_from_json(items: Any, *, file: str, source: str | None) -> tuple[list[dict], list[dict]]:
rows: list[dict] = []
skipped: list[dict] = []
if not isinstance(items, list):
return rows, [{"file": file, "line": 1, "reason": "a JSON input must be a list"}]
for n, item in enumerate(items, 1):
if isinstance(item, str):
row, why = _row_from_positional([item], file=file, line=n, source=source)
elif isinstance(item, dict):
cells: list[str] = []
fields: dict[int, str] = {}
for key, value in item.items():
field = _HEADER_FIELDS.get(_fold(str(key)))
if field is None or value is None:
continue
fields[len(cells)] = field
cells.append(str(value))
row, why = _row_from_named(cells, fields, file=file, line=n, source=source)
else:
row, why = None, "an item must be a string or an object"
if row is None:
skipped.append({"file": file, "line": n, "reason": why})
else:
rows.append(row)
return rows, skipped
_MAX_INTAKE_BYTES = 20 * 1024 * 1024
def _rows_from_file(path: Path, source: str | None) -> tuple[list[dict], list[dict]]:
"""One file's rows and the lines it could not use. The extension decides how lines are cut:
`.json` is a list, `.sql` is statements split on `;`, `.txt` and `.md` are one question per
line, and everything else is CSV."""
file = path.name
if path.stat().st_size > _MAX_INTAKE_BYTES:
raise ValueError(f"{file} is {path.stat().st_size // (1024 * 1024)} MB; the intake reads files up to {_MAX_INTAKE_BYTES // (1024 * 1024)} MB. Export fewer rows, or split the file.")
text = path.read_text(encoding="utf-8")
suffix = path.suffix.lower()
if suffix == ".json":
return _rows_from_json(json.loads(text), file=file, source=source)
if suffix == ".sql":
rows, skipped = [], []
for n, stmt in enumerate((s for s in text.split(";") if s.strip()), 1):
# The same test a CSV cell gets: anything that is not a SELECT or a WITH is context or
# a mistake, and never reaches the tier as a statement the person supplied.
if _is_statement(stmt):
rows.append(_new_row(file=file, line=n, source=source, statement=stmt.strip()))
else:
skipped.append({"file": file, "line": n, "text": stmt.strip()[:80],
"reason": "not a SELECT or WITH statement"})
return rows, skipped
if suffix in (".txt", ".md") or ("," not in text and "\t" not in text):
rows = []
for n, line in enumerate(text.splitlines(), 1):
if line.strip():
rows.append(_row_from_positional([line], file=file, line=n, source=source)[0])
return rows, []
with path.open(newline="", encoding="utf-8") as fh:
# A line whose first cell starts with `#` is guidance, the way the template the skill
# writes for the person carries it; it is never a row.
numbered = []
guidance: list[dict] = []
seen_row = False
for n, r in enumerate(csv.reader(fh), 1):
if not r or not any(c.strip() for c in r):
continue
if not seen_row and r[0].lstrip().startswith("#"):
# The template's guidance lines sit above the header; below it, "# of orders" is a
# label. Each skipped line is recorded, so a label swallowed here is at least visible.
guidance.append({"file": file, "line": n, "reason": "guidance line (starts with #)"})
continue
seen_row = True
numbered.append((n, r))
if not numbered:
return [], []
fields = _header_map(numbered[0][1], [r for _n, r in numbered[1:]])
data = numbered[1:] if fields is not None else numbered
rows, skipped = [], []
for n, cells in data:
if fields is not None:
row, why = _row_from_named(cells, fields, file=file, line=n, source=source)
else:
row, why = _row_from_positional(cells, file=file, line=n, source=source)
if row is None:
skipped.append({"file": file, "line": n, "reason": why})
else:
rows.append(row)
skipped.extend(guidance)
return rows, skipped
def _merge_by_label(rows: list[dict]) -> list[dict]:
"""A statement whose label matches a tile's label joins that tile's row; anything unmatched
keeps its own row. Matching is the fold only, so `q3 revenue` meets `Q3 Revenue` and nothing
looser does."""
tiles: dict[str, dict] = {}
for row in rows:
if row["expected"] is not None and row["statement"] is None and row["label"]:
tiles.setdefault(_fold(row["label"]), row)
merged: list[dict] = []
for row in rows:
key = _fold(row["label"] or row["question"] or "")
if (row["statement"] is not None and row["expected"] is None and key in tiles
and tiles[key]["statement"] is None):
tile = tiles[key]
tile["statement"] = row["statement"]
tile["provenance"]["shape"] = _row_shape(tile)
tile["provenance"]["merged_from"] = {"file": row["provenance"]["file"],
"line": row["provenance"]["line"]}
continue
merged.append(row)
return merged
def intake(paths: list[Path], *, source: str | None = None) -> dict:
"""Every file's rows, merged by label across files, with the shape that was seen.
`shape` is one letter when every row has the same shape and `mixed` otherwise; each row also
carries its own under `provenance.shape`, which is what the skill reads row by row.
"""
rows: list[dict] = []
skipped: list[dict] = []
for path in paths:
got, missed = _rows_from_file(Path(path).expanduser(), source)
rows.extend(got)
skipped.extend(missed)
rows = _merge_by_label(rows)
# The row number is given once, here. The intake page shows it, its block names it, and the run
# directory and the report page use it; a row dropped on the page never renumbers the others.
for n, row in enumerate(rows, 1):
row.setdefault("row", n)
shapes = {row["provenance"]["shape"] for row in rows}
shape = next(iter(shapes)) if len(shapes) == 1 else ("mixed" if shapes else None)
return {"shape": shape, "rows": rows, "skipped": skipped}
# --- Ledger ---------------------------------------------------------------
#
# One grade per part of a statement the person supplied, read from fixed filenames in the row's
# directory: what happened when it ran (`run.json`), what `sm prepare` and `sm receipt` said about
# it, what `sm join-probes` and `sm filter-values judge` reported, and the probe CSVs the execution
# tier returned. Four grades, and only measurement can earn `model_gap`:
# confirmed the statement and the semantic model agree, and the data backs it
# model_gap the data proves the statement right where the semantic model is missing or wrong
# query_defect the data proves the statement wrong
# unresolved the part could not be checked, and the note says why
# The rules have a dependency in them, and it is applied rather than assumed: a join that could not
# be graded leaves the fan-out check on its aggregate `unresolved`, said out loud, never clean.
CONFIRMED = "confirmed"
MODEL_GAP = "model_gap"
QUERY_DEFECT = "query_defect"
UNRESOLVED = "unresolved"
# A fifth word that is not a grade: a fact the run states and never judges (rows an inner join
# dropped, a wide column nobody would list). Ranked below `confirmed` so it never decides a row's
# verdict, never blocks an example, and is rendered in its own block.
NOTED = "noted"
_VERDICT_RANK = {QUERY_DEFECT: 3, UNRESOLVED: 2, MODEL_GAP: 1, CONFIRMED: 0, NOTED: -1}
# Error-classifier kinds that mean the statement itself is wrong, as opposed to the connection.
_STATEMENT_DEFECT_KINDS = {"column_not_found", "table_not_found", "syntax"}
# Guard rules that mean the statement wanted something the semantic model does not expose.
_SCOPE_RULES = {"table_scope", "column_scope"}
# Pre-flight risks that describe how the aggregate itself was written, not how a join fanned it.
_AGGREGATION_RISKS = {"bad_aggregation", "semi_additive"}
def _part(part: str, verdict: str, *, kind: str | None = None, depends_on=(),
evidence: dict | None = None, note: str = "") -> dict:
return {"part": part, "verdict": verdict, "kind": kind, "depends_on": list(depends_on),
"evidence": evidence or {}, "note": note}
def _load_json(path: Path) -> Any:
"""The JSON in `path`; None when the file is absent; `{"error": ...}` when it is empty or is not
JSON. A verb that crashed leaves a zero-byte redirect behind, and that must read as "this input
is unusable", never as "checked and clean"."""
if not path.exists():
return None
text = path.read_text(encoding="utf-8")
if not text.strip():
return {"error": "empty_file"}
try:
return json.loads(text)
except json.JSONDecodeError as exc:
return {"error": "unreadable_json", "detail": str(exc).splitlines()[0]}
def _usable(payload: Any, key: str) -> "tuple[dict | None, str | None]":
"""The payload when it carries `key`, else None and why: absent, empty, an error object from a verb
that exited non-zero, or JSON of another shape."""
if payload is None:
return None, "was not written"
if not isinstance(payload, dict):
return None, "is not a JSON object"
if payload.get("error"):
return None, f"carries an error ({payload['error']})"
if key not in payload:
return None, f"has no `{key}` key"
return payload, None
def _probe_csv(path: Path) -> "list[dict] | str | None":
"""A probe's CSV as rows; None when the file is absent; the string `failed` when it is empty.
The execution tier writes CSV to stdout only on success. A probe that was refused or failed
leaves a zero-byte file behind, and reading that as "the column holds no values" would turn a
failed probe into a definite grade. A header-only file is the legitimately empty result.
"""
if not path.exists():
return None
if path.stat().st_size == 0:
return "failed"
with path.open(newline="", encoding="utf-8") as fh:
return list(csv.DictReader(fh))
def _first_number(rows, key: str) -> float | None:
"""The first row's `key` column as a number. Headers are matched without regard to case, because
one tier upper-cases them; the fall-back to the only column is for a one-column result and never
for a wider one, where it would read the wrong column."""
if not isinstance(rows, list) or not rows:
return None
row = rows[0]
raw = next((v for k, v in row.items() if k and k.strip().lower() == key.lower()), None)
if raw is None and len(row) == 1:
raw = next(iter(row.values()))
try:
return float(raw) if raw not in (None, "") else None
except (TypeError, ValueError):
return None
def _grade_question_fit(fit: Any, ran: bool) -> list[dict]:
"""The one part graded by reading rather than measuring: the skill's Phase 1.5g judgment of
whether the statement answers the question it came with, written to `question_fit.json`. It can
withhold a row from the keep-offer and never proves anything about the semantic model. Expected
for every statement row after a run that succeeded, so a check that was never made is an open
part and not a silent pass."""
got, why = _usable(fit, "fit")
if got is None:
if not ran:
return []
return [_part("question_fit", UNRESOLVED, evidence={"file": "question_fit.json", "problem": why},
note=f"question_fit.json {why}, so the fit of the statement to its question was not checked")]
word, reason = got.get("fit"), got.get("reason")
if word == "no_question":
return []
if word == "plausible":
return [_part("question_fit", CONFIRMED, evidence={"fit": word, "reason": reason},
note="the statement plausibly answers the question, by reading; a judgment, not a measurement")]
if word == "doubtful":
return [_part("question_fit", UNRESOLVED, evidence={"fit": word, "reason": reason},
note=f"the statement may not answer the question: {reason or 'no reason was given'}; "
"reword the question or the statement and re-run this row")]
return [_part("question_fit", UNRESOLVED, evidence={"fit": word},
note=f"question_fit.json carries an unknown fit {word!r}, so the fit was not checked")]
def _grade_run(run: dict | None) -> list[dict]:
if run is None:
return [_part("runs", UNRESOLVED, note="no run record was found for the statement")]
status, rule, kind = run.get("status"), run.get("rule"), run.get("kind")
if status == "ok":
return [_part("runs", CONFIRMED, note="the statement ran"),
_part("scope", CONFIRMED, note="every table and column it named is in the semantic model")]
if status == "refused":
if rule in _SCOPE_RULES:
return [
_part("runs", UNRESOLVED,
note=f"the statement was refused before it ran ({rule}); see the scope part"),
_part("scope", MODEL_GAP, kind="scope",
evidence={"rule": rule, "detail": run.get("detail")},
note="the statement names a table or column the semantic model does not expose"),
]
if rule == "select_star":
return [_part("runs", QUERY_DEFECT, evidence={"rule": rule},
note="SELECT * is refused; name the columns")]
return [_part("runs", UNRESOLVED, evidence={"rule": rule},
note=f"the statement was refused before it ran ({rule})")]
if status == "failed":
if kind in _STATEMENT_DEFECT_KINDS:
return [_part("runs", QUERY_DEFECT, evidence={"kind": kind, "remediation": run.get("remediation")},
note=f"the database rejected the statement ({kind})")]
return [_part("runs", UNRESOLVED, evidence={"kind": kind},
note=f"the run failed with {kind}; the statement could not be checked")]
return [_part("runs", UNRESOLVED, note="the statement was not run")]
def _join_tables(join: dict) -> tuple[str, str]:
"""The two tables a join is between, sorted: from its one written pair when it has one, and
from its endpoint labels otherwise."""
pairs = join.get("pairs") or []
if len(pairs) == 1 and len(pairs[0]) == 2:
a, b = pairs[0][0][0], pairs[0][1][0]
else:
a, b = (join.get("endpoints") or ["", ""])[:2]
a, b = _fold(a), _fold(b)
first, second = sorted((a, b))
return first, second
def _join_status(join: dict) -> str:
"""The status `sm join-probes` gave the join; one it did not label stays open."""
return join.get("status") or "undetermined"
def _cardinality_result(probes: dict | None, key: str, row_dir: Path) -> dict | None:
"""One endpoint's uniqueness: from the semantic model when it declares the column a key, else
from the column's cardinality CSV, which is shared by every join that reads that column."""
if ((probes or {}).get("unique_by_model") or {}).get(key):
return {"unique": True, "source": "the semantic model declares the column a key"}
got = _probe_csv(row_dir / f"cardinality.{key}.csv")
if not isinstance(got, list) or not got:
return None
total = _first_number(got, "total")
distinct = _first_number(got, "distinct_count")
nulls = _first_number(got, "null_count") or 0.0
if total is None or distinct is None:
return None
return {"total": total, "distinct": distinct, "nulls": nulls,
"unique": distinct == total - nulls, "source": "probe"}
def _grade_joins(probes: dict | None, row_dir: Path) -> list[dict]:
rows: list[dict] = []
if probes is not None and probes.get("unreadable"):
return [_part("join:*", UNRESOLVED, evidence={"unreadable": probes["unreadable"]},
note="the join verb could not read the statement, so no join was checked")]
seen: dict[str, int] = {}
for join in (probes or {}).get("joins", []):
a, b = _join_tables(join)
label = f"{a}-{b}"
# Two joins between the same two tables in one statement are two parts, not one: keyed by
# the same label, the second would silently overwrite the first's grade.
seen[label] = seen.get(label, 0) + 1
if seen[label] > 1:
label = f"{label}#{seen[label]}"
jid = join.get("id", "join")
status = _join_status(join)
planned = join.get("probes") or {}
declared_pairs = join.get("declared_pairs", [])
written = {"pairs": join.get("pairs"), "predicate": join.get("predicate")}
overlaps: list[float | None] = []
overlap_failed = False
for i, _probe in enumerate(planned.get("overlap", [])):
got = _probe_csv(row_dir / f"{jid}.overlap.{i}.csv")
if got == "failed":
overlap_failed = True
elif got is not None:
overlaps.append(_first_number(got, "matched"))
card = {key: result for key in planned.get("cardinality", [])
if (result := _cardinality_result(probes, key, row_dir)) is not None}
hits = [m for m in overlaps if m is not None]
any_overlap = any(m > 0 for m in hits)
one_row_on_right = _one_row_on_right(join, probes)
if status in ("undeclarable", "undetermined"):
rows.append(_part(f"join:{label}", UNRESOLVED, evidence=written,
note=join.get("not_probed_because")
or "the join could not be resolved to two declared tables"))
continue
if status == "declared":
rows.append(_part(f"join:{label}", CONFIRMED, evidence={"declared_pairs": declared_pairs},
note="the join is on the key the semantic model declares"))
elif status == "wrong_key":
rows.append(_part(f"join:{label}", QUERY_DEFECT,
evidence={"declared_pairs": declared_pairs, **written},
note="the join is on a different key than the one the semantic model declares"))
elif join.get("too_big_to_probe") or not planned.get("overlap"):
rows.append(_part(f"join:{label}", UNRESOLVED, evidence=written,
note="the join is not declared and no probe could be run: "
+ (join.get("not_probed_because") or "no probe was planned")))
elif any_overlap:
rows.append(_part(f"join:{label}", MODEL_GAP, kind="relationship",
evidence={"overlap": hits, **written},
note="the join is not declared, and its keys resolve in the data"))
elif hits and not overlap_failed:
rows.append(_part(f"join:{label}", QUERY_DEFECT, evidence={"overlap": hits, **written},
note="the join is not declared, and no key on one side is found on the other"))
else:
# No hit, or a hit beside a probe that failed: half the evidence is not evidence.
rows.append(_part(f"join:{label}", UNRESOLVED, evidence={"overlap": hits, **written},
note="the join is not declared and "
+ ("a probe file is empty, so that probe likely failed; the rest is not enough to decide"
if overlap_failed else "no probe result was supplied")))
# Whether this join brings in one row at most per row of the table it joins to, by the
# semantic model's own word. The aggregate grader reads it; nothing is re-derived there.
rows[-1]["evidence"]["one_row_on_right"] = one_row_on_right
# The probe rows: whenever probes were planned or answered. A declared join plans none.
if hits or planned.get("overlap"):
if any_overlap:
rows.append(_part(f"join_key:{label}", CONFIRMED, evidence={"overlap": hits},
note="sampled keys from one side exist on the other"))
elif hits and not overlap_failed:
rows.append(_part(f"join_key:{label}", QUERY_DEFECT, evidence={"overlap": hits},
note="no sampled key from either side exists on the other"))
else:
rows.append(_part(f"join_key:{label}", UNRESOLVED, evidence={"overlap": hits},
note="an overlap probe result is missing or its file is empty"))
if card or planned.get("cardinality"):
uniques = sorted(k for k, v in card.items() if v["unique"])
if len(card) >= 2 and uniques:
rows.append(_part(f"cardinality:{label}", CONFIRMED,
evidence={"one_side": uniques[0], "sides": card},
note=f"{uniques[0]} is unique, so the join does not multiply rows"))
elif len(card) >= 2:
rows.append(_part(f"cardinality:{label}", QUERY_DEFECT, evidence={"sides": card},
note="both sides repeat, so the join multiplies rows"))
else:
rows.append(_part(f"cardinality:{label}", UNRESOLVED, evidence={"sides": card},
note="no cardinality result for both sides"))
rows.extend(_dropped_rows(join, label, jid, row_dir))
return rows
def _one_row_on_right(join: dict, probes: dict | None) -> bool:
"""True when the table this join introduces (its right endpoint) contributes one row at most per
row already there: it is the one side of the declared relationship the statement actually wrote,
or its written column is unique by the semantic model. False for a self-join and for anything
the model did not say. Sound for a chain, because each such join leaves the row count alone."""
endpoints = join.get("endpoints") or ["", ""]
left_key, right_key = _fold(str(endpoints[0])), _fold(str(endpoints[-1]))
if not right_key or left_key == right_key:
return False
matched_one_sides = {_fold(str(side)) for edge in (join.get("declared_cardinality") or [])
if edge.get("matched") for side in (edge.get("one_side") or [])}
if right_key in matched_one_sides:
return True
pairs = join.get("pairs") or []
if len(pairs) == 1 and len(pairs[0]) == 2:
# The probe file keys this map with the semantic model's own spelling; the pair carries the
# statement's, lowercased. Folded on both sides, so an uppercase-introspected model
# (`customers.ID`) still says its key is unique.
unique = {_fold(str(k)): v for k, v in ((probes or {}).get("unique_by_model") or {}).items()}
for table, column in pairs[0]:
if _fold(str(table)) == right_key and unique.get(_fold(f"{table}.{column}")):
return True
return False
def _dropped_rows(join: dict, label: str, jid: str, row_dir: Path) -> list[dict]:
"""The rows an inner join left behind, said and never judged. No probe planned → nothing to say."""
probe = join.get("dropped_rows_probe")
if not probe:
return []
left_t, right_t = probe.get("left"), probe.get("right")
unexamined = probe.get("unexamined")
got = _probe_csv(row_dir / f"{jid}.dropped_rows.csv")
# Both numbers by their own header, never the one-column fall-back: a result with one column
# would otherwise read as N of N dropped and be stated as a fact.
total = _named_number(got, "total")
dropped = _named_number(got, "dropped")
if total is None or dropped is None:
return [_part(f"dropped_rows:{label}", NOTED, evidence={"left": left_t, "right": right_t},
note="the dropped-rows probe was not run or failed; nothing is claimed")]
t, d = int(total), int(dropped)
said = (f"no {left_t} row is dropped by this join" if d == 0
else f"{d} of {t} {left_t} rows have no {right_t} partner and are dropped by this inner join")
said += "; counted over the whole table, before the statement's own filters"
if unexamined:
said += f"; rows of {unexamined} with no {left_t} partner were not counted"
return [_part(f"dropped_rows:{label}", NOTED,
evidence={"total": t, "dropped": d, "left": left_t, "right": right_t, "unexamined": unexamined},
note=said)]
def _named_number(rows, key: str) -> float | None:
"""The first row's `key` column as a number, by header only, whatever the header's case."""
if not isinstance(rows, list) or not rows:
return None
raw = next((v for k, v in rows[0].items() if k and k.strip().lower() == key.lower()), None)
try:
return float(raw) if raw not in (None, "") else None
except (TypeError, ValueError):
return None
def _joins_named(label: str, join_rows: list[dict]) -> list[str]:
"""The `join:` parts whose two tables both appear in a pre-flight join label."""
words = set(re.findall(r"[a-z0-9_]+", _fold(label)))
out = []
for row in join_rows:
if not row["part"].startswith("join:"):
continue
a, b = row["part"][len("join:"):].split("#", 1)[0].split("-", 1)
if a in words and b in words:
out.append(row["part"])
return out
def _grade_aggregates(prepare: dict | None, join_rows: list[dict], probes: dict | None = None,
no_joins_written: bool = False) -> list[dict]:
"""`no_joins_written` is settled by `sm join-probes` having read the statement and counted zero
joins written: a statement that writes no join has nothing that can multiply its aggregates,
however the pre-flight labelled them, so an `undetermined` there is confirmed rather than left
open. A verb that could not read the statement settles nothing. `probes` is the same file, read
for the sibling rule: every join written brings in one row at most."""
if prepare is None:
return []
if prepare.get("unchecked"):
return [_part("fan_out:*", UNRESOLVED, evidence={"unchecked": prepare["unchecked"]},
note=f"the pre-flight did not run: {prepare['unchecked']}")]
rows: list[dict] = []
by_part = {row["part"]: row for row in join_rows}
# Every join the statement wrote, not the aggregate's own `joins` list: the pre-flight fills
# that list from multiplying findings only, so it is empty for exactly the aggregate this rule
# is for. The rule needs every written join listed (none dropped at the cap, the verb having
# read the statement), every one confirmed, and every one bringing in one row at most.
join_parts = [row for row in join_rows if row["part"].startswith("join:") and row["part"] != "join:*"]
one_row_joins = (
isinstance(probes, dict) and probes.get("unreadable") is None
and probes.get("dropped") == 0 and probes.get("joins_written") == len(probes.get("joins") or [])
and bool(join_parts)
and all(row["verdict"] == CONFIRMED and row["evidence"].get("one_row_on_right") for row in join_parts)
)
for agg in prepare.get("aggregates", []):
text = agg.get("aggregate", "?")
risks = {f.get("risk") for f in agg.get("findings", [])}
deps = sorted({p for label in agg.get("joins", []) for p in _joins_named(label, join_rows)})
weak = [p for p in deps if by_part[p]["verdict"] != CONFIRMED]
if weak:
rows.append(_part(f"fan_out:{text}", UNRESOLVED, depends_on=deps,
note=f"the join {weak[0][len('join:'):]} this total depends on is "
f"{by_part[weak[0]]['verdict']}, so the fan-out check has no "
"cardinality to reason from"))
elif agg.get("status") == "multiplied":
if risks and risks <= {"fan_out_invariant"}:
rows.append(_part(f"fan_out:{text}", CONFIRMED, depends_on=deps,
note="a join multiplies the rows, but this aggregate cannot move"))
else:
named = sorted(risks - {"fan_out_invariant"}) or ["multiplied"]
rows.append(_part(f"fan_out:{text}", QUERY_DEFECT, depends_on=deps,
evidence={"risks": named, "joins": agg.get("joins", [])},
note=f"a join multiplies the rows this total is computed from "
f"({', '.join(named)})"))
elif agg.get("status") == "not_multiplied":
rows.append(_part(f"fan_out:{text}", CONFIRMED, depends_on=deps,
note="no join multiplies the rows behind this aggregate"))
elif no_joins_written:
rows.append(_part(f"fan_out:{text}", CONFIRMED, depends_on=deps,
note="the statement writes no join, so nothing multiplies this aggregate"))
elif one_row_joins:
rows.append(_part(f"fan_out:{text}", CONFIRMED, depends_on=[row["part"] for row in join_parts],
evidence={"joins": [row["part"] for row in join_parts]},
note="every join the statement writes brings in one row at most, so no join "
"multiplies this aggregate"))
else:
# The pre-flight names the blindness it hit; the note repeats it rather than blaming a join.
reason = agg.get("reason") or "no reason was given"
rows.append(_part(f"fan_out:{text}", UNRESOLVED, depends_on=deps, evidence={"reason": agg.get("reason")},
note=f"the pre-flight could not bind this aggregate to one table: {reason}"))
bad = sorted(risks & _AGGREGATION_RISKS)
if bad:
rows.append(_part(f"aggregation:{text}", QUERY_DEFECT, evidence={"risks": bad},
note=f"the aggregate is not legal over this column ({', '.join(bad)})"))
else:
rows.append(_part(f"aggregation:{text}", CONFIRMED, note="the aggregate is legal over its column"))
return rows
def _grade_filters(receipt: dict | None) -> list[dict]:
rows: list[dict] = []
for item in ((receipt or {}).get("tables") or {}).get("items", []):
table = item.get("ref") or item.get("qname") or "?"
for flt in item.get("filters", []) or []:
part = f"default_filter:{table}:{flt.get('expr')}"
status = flt.get("status")
if status == "applied":
rows.append(_part(part, CONFIRMED, note="the declared filter is applied"))
elif status == "omitted":
rows.append(_part(part, MODEL_GAP, kind="filter", evidence={"table": table, "expr": flt.get("expr")},
note="the semantic model declares this filter and the statement does not apply it"))
else:
rows.append(_part(part, UNRESOLVED, note="whether the declared filter is applied could not be read"))
return rows
def _grade_metrics(receipt: dict | None, prepare: dict | None) -> list[dict]:
rows: list[dict] = []
aggregates = [_fold(a.get("aggregate", "")) for a in (prepare or {}).get("aggregates", [])]
prepare_read = isinstance(prepare, dict) and "aggregates" in prepare
only_bare_counts = bool(aggregates) and all(a == "count(*)" for a in aggregates)
for item in ((receipt or {}).get("columns") or {}).get("items", []):