forked from ChelseaKR/outcome-receipts
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmodels.py
More file actions
455 lines (365 loc) · 18.6 KB
/
Copy pathmodels.py
File metadata and controls
455 lines (365 loc) · 18.6 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
"""Core data types.
A Receipt is the unit of trust: it records exactly how a number was produced, so
the number can be reproduced and audited. A Figure is a value plus its receipt. A
MetricSpec defines how to compute a figure deterministically. Nothing here calls
a model; figures come from queries, never from generated text.
"""
from __future__ import annotations
from dataclasses import dataclass, field
# The hash that stands in for "no data slice", used when a metric is computed
# over an empty result set. Distinct from a real slice hash so an empty slice is
# visible rather than silently indistinguishable.
EMPTY_SLICE_HASH = "0" * 64
# What a reader sees in place of a figure small-cell suppression withheld. It is
# a redaction marker, not narrative copy, so it is never translated. Single
# sourced here because suppression writes it, the report and trace views render
# it, and the evidence workflows branch on it; three copies of the literal is
# three chances for one of them to drift out of agreement with the others.
REDACTED_DISPLAY = "[SUPPRESSED]"
# The schema version of the receipts manifest. Bumped when the shape of
# receipts.json or the meaning of a receipt field changes in a way that a
# consumer or the re-derivation check must know about. ``verify`` names a
# version mismatch before it tries to re-derive fields, so a manifest written
# under an older schema fails with a clear reason rather than as opaque drift.
#
# 2.0 is a breaking change: a suppressed receipt's withheld numerics are ``null``
# and it carries ``suppressed: true``. Under 1.0 they were zeros, so a consumer
# reading the numeric field could not tell a withheld cell from a true zero. See
# docs/SPEC-STABILITY.md for the field mapping.
SCHEMA_VERSION = "2.0"
# Manifest schema versions ``verify`` can still re-derive. Reading an older
# manifest is supported (the 1.0 rendering of a suppressed receipt is
# reconstructable from the current figures); writing one is not.
SUPPORTED_SCHEMA_VERSIONS = ("1.0", "2.0")
# The hash descriptor. It rides in the manifest so a consumer knows exactly how
# every ``slice_hash`` was produced without reading the engine. ``canonicalization``
# names the rule set that turns a slice into the hashed bytes (see the ADR); it is
# bumped whenever those rules change, because a changed canonicalization changes
# every hash. ``v1`` folds the sorted column names into the payload, so a slice of
# identical values under renamed columns hashes differently.
HASH_ALGORITHM = "blake2b"
HASH_DIGEST_SIZE = 32
HASH_CANONICALIZATION = "v1"
@dataclass(frozen=True)
class MetricSpec:
"""How to compute one figure, deterministically.
``value_sql`` is a query returning a single scalar (the figure's value).
``slice_sql`` returns the rows the figure is computed over; it is used for the
row count and the slice hash, the evidence that the value came from a specific
set of records. ``unit`` selects formatting: ``count``, ``percent``, ``money``
(a ``$``-prefixed amount), ``duration`` (a number of ``days``), or ``rate`` (a
bare fixed-decimal number). ``decimals`` sets the fixed-decimal places; ``money``
typically uses 2. Each unit has one canonical display form (see ADR 0004), the
single string the grounding gate binds.
``description`` is a short label. ``definition`` is the precise, plain-language
statement of what the figure counts: the time window, who is in scope, and the
deduplication rule. The definition rides in the receipt and renders next to the
figure, so a reviewer can see and contest the choices a query encodes (a count
of "clients served" is only as fair as its definition) instead of inferring
them from the SQL.
``kind`` distinguishes an ``output`` (an activity count, such as clients
served) from an ``outcome`` (a change in condition, such as a housing-retention
rate). It rides in the receipt so a reader does not misread a busy output as
the outcome it is meant to produce.
``indicator``, ``data_source``, and ``collection_frequency`` are the optional
logic-model mapping. They tie the figure to a row in a theory of change: the
named indicator it measures, the system the data comes from, and how often that
data is collected. Each defaults to empty, which means the figure is not mapped;
when set they ride into the receipt so the mapping travels with the number.
``caveat`` is an optional qualifying note (e.g. a data-quality limitation)
that travels with the receipt, so a limitation on the figure rides inside the
receipt chain and renders next to the figure instead of living as loose prose.
"""
metric_id: str
description: str
value_sql: str
slice_sql: str
unit: str = "count"
decimals: int = 0
definition: str = ""
kind: str = "output"
indicator: str = ""
data_source: str = ""
collection_frequency: str = ""
caveat: str = ""
@dataclass(frozen=True)
class DataCheck:
"""An author-declared data-quality precondition, asserted before compute.
``assert_sql`` is a query returning a single scalar; a nonzero/true value
passes and a falsy value (None, 0, "0", "", "false") fails. Checks state the
preconditions a report's figures rely on -- no null client ids, dates inside
the reporting window, no duplicate keys -- and run before any figure is
computed, so a violated precondition fails closed and blocks the whole run
rather than producing a receipted-but-wrong number. ``message`` is an optional
author note appended to the failure so the person fixing the data knows what
the check was defending.
"""
check_id: str
description: str
assert_sql: str
message: str = ""
@dataclass(frozen=True)
class Receipt:
"""The record of how a figure was produced.
``slice_hash`` is a BLAKE2b hash of the canonicalized rows the figure was
computed over, so the same data reproduces the same receipt and a changed
slice is detectable. ``column_names`` records the slice's columns (in query
order), so the receipt is self-describing about what was hashed and a renamed
column is visible rather than silent; those names are folded into the hash
payload, so two slices with identical values under different column names
hash differently. ``computed_at`` comes from an injected clock so a committed
eval is reproducible. ``unit`` is the metric's unit (``count``, ``percent``,
``money``, ``duration``, or ``rate``), carried so a consumer can re-derive the
display. ``definition`` carries the figure's plain-language
definition forward from its ``MetricSpec`` so the receipt is self-describing
without the spec on hand. ``kind`` carries the same forward label
distinguishing an activity count (``output``) from a change in condition
(``outcome``), so a reader of the receipt alone does not misread an output as
an outcome. ``indicator``, ``data_source``, and ``collection_frequency`` carry
the logic-model mapping forward the same way, so a receipt states which
theory-of-change indicator its number belongs to.
``caveat`` carries the figure's optional qualifying note (e.g. a
data-quality limitation) forward the same way, so the limitation rides inside
the receipt chain rather than as loose prose.
``suppressed`` says whether small-cell suppression withheld this figure. It
is the field a machine consumer branches on, and it is why the withheld
numerics are typed ``| None``: a suppressed receipt carries ``None`` for
``value``, ``row_count``, ``slice_hash``, and ``column_names``, never a zero.
Three states have to stay three states. A figure that is genuinely zero
carries ``suppressed=False`` with ``value=0.0``, ``row_count=0``, and the
all-zero ``EMPTY_SLICE_HASH``; a figure that is withheld carries
``suppressed=True`` and nothing numeric at all; a figure that does not exist
has no ``Receipt``. Writing a zero for a withheld cell told every downstream
reader "we served nobody", which is a worse answer to a funder than "we
cannot report that figure" and is not the answer the report makes in prose.
"""
metric_id: str
value_sql: str
row_count: int | None
slice_hash: str | None
value: float | None
unit: str
computed_at: str
definition: str = ""
kind: str = "output"
indicator: str = ""
data_source: str = ""
collection_frequency: str = ""
caveat: str = ""
column_names: tuple[str, ...] | None = ()
suppressed: bool = False
@dataclass(frozen=True)
class Figure:
"""A computed value, its display string, and the receipt that backs it.
``value`` is ``None`` when small-cell suppression withheld the figure, for
the same reason its receipt's numerics are: it is the field renderers read
for geometry, and a suppressed figure carrying ``0.0`` drew a bar of height
zero and a line straight through the axis floor. There is no value here to
draw. A renderer must check ``receipt.suppressed`` (or ``value is None``)
and draw an absence, not a quantity.
"""
metric_id: str
value: float | None
display: str
receipt: Receipt
@dataclass(frozen=True)
class PeriodSpec:
"""One reporting period in a multi-period comparison.
``predicate`` is a SQL boolean over the data table that selects the period's
rows (for example a date window). It is substituted into a comparison metric's
``{period}`` placeholder, so each period's figure is computed by the same
deterministic query restricted to that period. ``label`` is the human name
shown in tables and charts; it carries no number.
"""
period_id: str
label: str
predicate: str
@dataclass(frozen=True)
class ComparisonSpec:
"""A period-over-period comparison of a shared set of metrics.
Each metric in ``metrics`` uses a ``{period}`` placeholder in its SQL. The
comparison computes that metric once for ``prior`` and once for ``current``,
then a delta, each as a Figure with its own receipt. ``current`` and ``prior``
name two entries in ``periods``.
"""
current: str
prior: str
periods: tuple[PeriodSpec, ...] = field(default_factory=tuple)
metrics: tuple[MetricSpec, ...] = field(default_factory=tuple)
def period(self, period_id: str) -> PeriodSpec:
for spec in self.periods:
if spec.period_id == period_id:
return spec
raise KeyError(f"comparison references unknown period {period_id!r}")
@dataclass(frozen=True)
class ReconciliationRow:
"""One board line: a receipted outcome figure paired with its financial line.
``outcome`` and ``financial`` are period metrics written with the same
``{period}`` placeholder as comparison metrics, so each is computed once for the
prior period and once for the current period, then differenced by a single
query. Placing them on one row lets the board read an outcome figure next to
the money it took to produce it, with both numbers grounded. ``label`` names
the pairing; it carries no number.
"""
label: str
outcome: MetricSpec
financial: MetricSpec
@dataclass(frozen=True)
class ReconciliationSpec:
"""A board reconciliation: outcome figures beside financial lines, over two periods.
Each row pairs an outcome metric with a financial metric, both using the
``{period}`` placeholder exactly like a ``ComparisonSpec``. The reconciliation
computes every metric for ``prior`` and ``current`` and the delta between them,
each as a Figure with its own receipt, so a board can see how an outcome and its
financial line each moved period over period. ``current`` and ``prior`` name two
entries in ``periods``.
"""
current: str
prior: str
periods: tuple[PeriodSpec, ...] = field(default_factory=tuple)
rows: tuple[ReconciliationRow, ...] = field(default_factory=tuple)
def period(self, period_id: str) -> PeriodSpec:
for spec in self.periods:
if spec.period_id == period_id:
return spec
raise KeyError(f"reconciliation references unknown period {period_id!r}")
@dataclass(frozen=True)
class ChartSpec:
"""A chart drawn from already-computed, receipted figures.
``metric_ids`` names the figures whose values become the chart's data points,
so a chart has no data path of its own: its bars and points are the grounded
figures. ``kind`` is ``bar`` or ``line``. Every number the chart renders (its
value labels and its accessible data table) is a figure display, so the
grounding gate verifies a chart exactly as it verifies prose.
"""
chart_id: str
title: str
kind: str
metric_ids: tuple[str, ...]
labels: tuple[str, ...] = field(default_factory=tuple)
def label_for(self, index: int) -> str:
"""The bar/point label for the figure at ``index``.
Falls back to the metric id when no explicit label is given, so a chart
is renderable without a parallel labels list.
"""
if index < len(self.labels):
return self.labels[index]
return self.metric_ids[index]
@dataclass(frozen=True)
class TemplateSpec:
"""One named funder template format for a report.
``template_id`` identifies the funder format and names the output subdirectory
the report renders into. ``title`` heads that funder's rendered report.
``template`` is plain text with ``{metric_id}`` placeholders, filled with the
same shared, receipted figures. Several ``TemplateSpec``s over one metric set
let a single run render the same figures into more than one funder format, each
held to the same grounding gate.
"""
template_id: str
title: str
template: str
@dataclass(frozen=True)
class DraftingSpec:
"""Optional narrative provider policy; deterministic and disabled by default."""
provider: str = "deterministic"
enabled: bool = False
model_id: str = ""
max_tokens: int = 1200
@dataclass(frozen=True)
class ReportSpec:
"""A report template plus the metrics it needs.
``template`` is plain text with ``{metric_id}`` placeholders. ``metrics`` are
the specs whose figures fill those placeholders. ``title`` heads the rendered
report. ``charts``, ``comparison``, and ``reconciliation`` are optional
sections; their numbers are figures too, held to the same grounding gate.
``data_checks`` are author-declared data-quality preconditions that assert
before any figure is computed and fail closed, so a bad export is refused
before a single number is produced.
``templates`` optionally names several funder formats over the same metrics. It
is empty for a legacy single-template spec; when empty, the legacy
``title``/``template`` pair is the sole default format (see
``effective_templates``), so existing specs keep rendering into the flat output
directory unchanged.
"""
title: str
template: str
metrics: tuple[MetricSpec, ...] = field(default_factory=tuple)
charts: tuple[ChartSpec, ...] = field(default_factory=tuple)
comparison: ComparisonSpec | None = None
data_checks: tuple[DataCheck, ...] = field(default_factory=tuple)
reconciliation: ReconciliationSpec | None = None
templates: tuple[TemplateSpec, ...] = field(default_factory=tuple)
drafting: DraftingSpec = field(default_factory=DraftingSpec)
@property
def effective_templates(self) -> tuple[TemplateSpec, ...]:
"""The funder formats to render, one per output.
When ``templates`` is set the run renders each named format. Otherwise the
legacy single template is synthesized into one ``TemplateSpec`` with id
``"report"``, so callers iterate the same shape either way while the
legacy spec still describes exactly one report.
"""
if self.templates:
return self.templates
return (TemplateSpec(template_id="report", title=self.title, template=self.template),)
@dataclass(frozen=True)
class NumericSpan:
"""A number found in drafted text, with where it was found."""
text: str
start: int
end: int
@dataclass(frozen=True)
class GroundingResult:
"""The outcome of the grounding gate over a narrative.
``bound`` are numeric spans that matched a figure's display; ``unbound`` are
spans that matched no figure and therefore block export. ``ok`` is true only
when nothing is unbound.
"""
bound: tuple[NumericSpan, ...]
unbound: tuple[NumericSpan, ...]
@property
def ok(self) -> bool:
return not self.unbound
@property
def total(self) -> int:
return len(self.bound) + len(self.unbound)
@dataclass(frozen=True)
class SuppressedSpan:
"""A number in prose that states a cell the report refuses to publish.
This is not an unbound span, and reporting it as one would send the author
looking for a missing metric. The number is real and fully receipted; it is
the raw value of a figure small-cell suppression redacted, so writing it into
a narrative discloses a protected cell. ``metric_ids`` names every suppressed
figure whose pre-suppression display canonicalizes to this number.
``publishable_metric_ids`` names any *publishable* figure that canonicalizes
to the same number. When it is non-empty the span is genuinely ambiguous: the
prose could be stating either figure, and nothing in the text says which. The
span is still classified as a disclosure -- resolving the ambiguity toward
"they must have meant the publishable one" is the unsafe direction -- but the
ambiguity is reported rather than hidden, so the author can see that the
number they wrote is also the exact value of a cell the report withholds.
"""
span: NumericSpan
metric_ids: tuple[str, ...]
publishable_metric_ids: tuple[str, ...] = ()
@property
def ambiguous(self) -> bool:
return bool(self.publishable_metric_ids)
@dataclass(frozen=True)
class AuditResult:
"""The outcome of auditing a narrative against the publishable figure set.
Three outcomes, not two. ``bound`` spans match a figure the report will
actually publish. ``suppressed`` spans match a figure suppression redacted,
so the narrative would publish a protected cell. ``unbound`` spans match
nothing at all. ``ok`` requires both failing categories to be empty, so a
narrative that discloses a protected cell fails exactly as hard as one that
invents a number.
"""
bound: tuple[NumericSpan, ...]
suppressed: tuple[SuppressedSpan, ...]
unbound: tuple[NumericSpan, ...]
@property
def ok(self) -> bool:
return not self.suppressed and not self.unbound
@property
def total(self) -> int:
return len(self.bound) + len(self.suppressed) + len(self.unbound)