Skip to content

Commit 127017a

Browse files
Merge pull request #26 from OpenStatSpec/docs/numeric-target-workflow
docs: document numeric target workflow
2 parents 63a66d6 + 361263e commit 127017a

1 file changed

Lines changed: 135 additions & 0 deletions

File tree

‎docs/transformations.md‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,141 @@ openstatspec apply-spss --database-url sqlite:///survey.sqlite \
135135

136136
On Dolt, also pass `--expected-branch` and `--expected-head`.
137137

138+
## Numeric targets: create versus replace
139+
140+
`COMPUTE` chooses its canonical target mode from the live schema. An absent
141+
numeric target lowers to `assign` with `target_mode=create`; it appends one
142+
nullable numeric column and one catalog variable. An existing numeric target
143+
lowers to `target_mode=replace`. Replace keeps the variable ID, ordinal, and
144+
metadata that no later operation explicitly changes. `IF` requires an existing
145+
target, so a new target must be initialized first. Variable-source `NULL`
146+
propagates through assignment; a predicate changes a row only when its SQL
147+
truth value is `TRUE`.
148+
149+
For example, this is a create-or-replace program for a numeric target. On
150+
SQLite/PostgreSQL it creates `target` when absent; with a pre-existing numeric
151+
`target` it updates the same target instead:
152+
153+
```text
154+
COMPUTE target = source_a.
155+
IF (source_a = 1 AND source_b = 1) target = 1.
156+
IF (target = 1 AND source_b = 1) target = 2.
157+
VARIABLE LABELS target 'Numeric target'.
158+
VALUE LABELS target 0 'No' 1 'Yes'.
159+
FORMATS target (F8.1).
160+
VARIABLE LEVEL target (NOMINAL).
161+
EXECUTE.
162+
```
163+
164+
`RECODE ... INTO` always requests a new target and rejects an existing target
165+
name. A canonical replace recode must use the same source and target name.
166+
Without `ELSE`, create recodes use `system_missing` for unmatched rows, while
167+
replace recodes use `copy`. To pre-provision `band` and preserve the create
168+
semantics, initialize and recode it explicitly. The absent-target form is:
169+
170+
```text
171+
RECODE source_a (1 = 0.1) (2 = 1) INTO band.
172+
```
173+
174+
The pre-existing-target form is:
175+
176+
```text
177+
COMPUTE band = source_a.
178+
RECODE band (1 = 0.1) (2 = 1) (ELSE = SYSMIS).
179+
```
180+
181+
The second form has an extra ordered assignment and is not a pure DDL-overhead
182+
comparison. Omitting its `ELSE` would copy unmatched source values instead of
183+
matching the create form. For canonical plans, create recodes use
184+
`target_mode=create` and replace recodes use `source=target`,
185+
`target_mode=replace`.
186+
187+
On SQLite and PostgreSQL, numeric create is supported only where the adapter
188+
has the atomic native transaction boundary. MySQL, MariaDB, and Dolt reject a
189+
create target with `schema_change_not_atomic`. A caller must use a separate,
190+
explicit, versioned provisioning action that creates both the nullable physical
191+
column and its matching normative variable, including a unique variable ID,
192+
the same physical binding, and the next ordinal. Provisioning is not an apply
193+
step, and this package does not hide it behind a new helper or API. See the
194+
specification's [In-Place Transformation Binding 0.2 target-creation
195+
rules](https://github.com/OpenStatSpec/specification/blob/main/docs/transformation-plan-sql-binding-0.2.md#target-creation-by-sql-profile).
196+
For Dolt, commit provisioning separately; the later apply starts from that
197+
clean committed `HEAD` and supplies it as `expected_head`. The SQLite matrix
198+
below is local evidence only and is not service or cross-engine evidence.
199+
200+
## Local numeric-target measurement
201+
202+
The following is a bounded observation of the existing workflow, not a
203+
release-to-release benchmark. A disposable standard-library harness created a
204+
fresh file-backed SQLite database for every variant and repetition, kept
205+
fixture setup separate from the timed public call, and removed all temporary
206+
files afterward. It covered two workloads (A: the `COMPUTE`/`IF` program above;
207+
B: `RECODE`), create versus pre-provisioned replace, 100 versus 100,000 cases,
208+
and both `apply_transformation_plan_in_place` and `apply_spss_in_place`. Each
209+
cell had one counted fresh apply, one fresh warm-up, and five fresh timed
210+
applies; create/replace order alternated by repetition. Each counted,
211+
warm-up, and timed apply started from a newly created fixture database; no
212+
apply reused a database from another trial. Rows were generated by cycling
213+
this input sequence in order: `(1,1)`, `(1,NULL)`, `(NULL,1)`, `(0,1)`,
214+
`(2,2)`, `(0.1,0)`, `(-9,1)`, `(NULL,0)`. Thus the 100-row runs used 12 full
215+
cycles plus the first four rows of a cycle, while the 100,000-row runs used
216+
12,500 full cycles. Each fixture had numeric `source_a` and `source_b`; `source_a` carried
217+
label `Source A` and missing rule `99`, while `source_b` carried label
218+
`Source B` and no missing rule. Replace fixtures added one nullable numeric
219+
ordinal-3 target (`target` for A, `band` for B) with label `Old target`, `F4.0`
220+
formats, ordinal measurement level, and missing rule `99`; create fixtures had
221+
no target column.
222+
223+
The counted value is an outermost SQLAlchemy `Connection.execute` or
224+
`Connection.exec_driver_sql` invocation scoped to the owned database. It
225+
includes profile probes, catalog checks, reflection, explicit `BEGIN`, DDL,
226+
DML, and audit work; one executemany call counts once. It is not a driver wire
227+
round-trip count. Timed values are the public apply call only: setup,
228+
validation, and cleanup are excluded. Every counted and timed result checked
229+
ordered rows, explicit `NULL`s, binary64 bits (including `0.1`), source and
230+
target identity, catalog/audit provenance, and absence of forbidden copy or
231+
snapshot artifacts before being retained.
232+
233+
Environment and revisions: Python 3.13.2, SQLite 3.47.1, SQLAlchemy 2.0.52,
234+
Linux 6.6.87.2-microsoft-standard-WSL2 on x86_64 with 12 logical CPUs and
235+
local temporary-file storage; observed file settings were
236+
`journal_mode=delete` and `synchronous=2` (`FULL`). The Python source was
237+
`63a66d6589aeb8fdc4820d5e7217fc13c61ef3b9`; the reviewed specification
238+
reference was `930345b922af4f43b0e622be02d4b12ebfeb08eb`. Fixture setup was
239+
recorded separately and excluded from the table.
240+
241+
| Workload | Cases | API | Mode | SQLAlchemy executes | Median ms | Min ms | Max ms |
242+
| --- | ---: | --- | --- | ---: | ---: | ---: | ---: |
243+
| A | 100 | canonical plan | create | 234 | 76.5 | 65.8 | 78.4 |
244+
| A | 100 | SPSS | create | 237 | 69.3 | 66.8 | 78.2 |
245+
| A | 100 | canonical plan | replace | 222 | 67.6 | 56.6 | 88.3 |
246+
| A | 100 | SPSS | replace | 225 | 71.3 | 69.3 | 88.5 |
247+
| A | 100,000 | canonical plan | create | 234 | 168.8 | 134.1 | 172.3 |
248+
| A | 100,000 | SPSS | create | 237 | 160.2 | 149.4 | 170.3 |
249+
| A | 100,000 | canonical plan | replace | 222 | 162.7 | 146.0 | 173.6 |
250+
| A | 100,000 | SPSS | replace | 225 | 155.4 | 144.8 | 177.6 |
251+
| B | 100 | canonical plan | create | 224 | 61.6 | 55.6 | 76.6 |
252+
| B | 100 | SPSS | create | 227 | 77.1 | 65.5 | 312.8 |
253+
| B | 100 | canonical plan | replace | 213 | 64.2 | 60.2 | 223.1 |
254+
| B | 100 | SPSS | replace | 216 | 80.9 | 62.2 | 237.5 |
255+
| B | 100,000 | canonical plan | create | 224 | 149.4 | 131.7 | 193.4 |
256+
| B | 100,000 | SPSS | create | 227 | 144.1 | 128.3 | 158.4 |
257+
| B | 100,000 | canonical plan | replace | 213 | 169.8 | 163.6 | 186.6 |
258+
| B | 100,000 | SPSS | replace | 216 | 192.8 | 171.4 | 202.8 |
259+
260+
The canonical plan operation/hash identities for the same runs were:
261+
262+
| Workload | Mode | Operations | Contract | Canonical plan SHA-256 |
263+
| --- | --- | ---: | --- | --- |
264+
| A | create | 8 | v0.2 | `6b17a220f9be9d2d29b671dd5eb4598ea34ae459e4d3b8e84a7daafbe3ec11ef` |
265+
| A | replace | 8 | v0.2 | `a239cac39d3fade1207228bee4296a444669eb31e154e6cea09f378ea75b12eb` |
266+
| B | create | 1 | v0.1 | `2057e57117100ba3efd5becf5254f47a5696bc8631c35480a5a6d2f5b1327f83` |
267+
| B | replace | 2 | v0.2 | `23b4bbf06383c5e73e04356af079db1e6fe0827f8afdb406f849169306fa2214` |
268+
269+
These observations do not establish service latency, wire round trips,
270+
row-independent cost, or a before/after speedup for any release. They are
271+
owned SQLite evidence for the documented behavior at the revisions above.
272+
138273
## Database and Dolt invariants
139274

140275
Every successful apply preserves the logical `dataset_id` and physical

0 commit comments

Comments
 (0)