Skip to content

Commit f7424a6

Browse files
Merge pull request #32 from OpenStatSpec/feat/official-frontend-03
feat: implement opt-in official SPSS Frontend 0.3
2 parents 200a421 + 10433d9 commit f7424a6

15 files changed

Lines changed: 822 additions & 77 deletions

‎CHANGELOG.md‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ All notable changes to this reference implementation are documented here.
44

55
## Unreleased
66

7+
### Added
8+
9+
- Implement explicitly selected official SPSS Frontend 0.3 over unchanged
10+
Plan 0.1/0.2 through the shared parser/binder, with a strict
11+
`compile_spss_request` boundary and opt-in live apply provenance. Support
12+
comments, dictionary-order `TO`, grouped commands, finite open RECODE ranges,
13+
additive ordered typed labels, inequality aliases, and canonical NOT lowering.
14+
Precedence is comparisons → NOT → AND → OR; parentheses override it.
15+
Tests cover all 90 effective normative cases plus request boundaries and
16+
in-place SQLite identity/metadata/audit/no-artifact checks. Default APIs and
17+
Python extension support/rejections remain unchanged.
18+
719
### Fixed
820

921
- Namespace new Python schema-change plans and SPSS output as
@@ -12,9 +24,9 @@ All notable changes to this reference implementation are documented here.
1224
The former `openstatspec-transformation-plan-v0.3` remains accepted as a
1325
legacy Python extension with unchanged semantics, canonical JSON, and hashes;
1426
stored audits are not migrated. New compilation changes schema-plan hashes.
15-
- Clarify that specification `v0.5.0` has no official Plan 0.3 and that its
16-
syntax-only Frontend 0.3 over Plan 0.1/0.2 is not implemented here. Legacy
17-
acceptance and Python extension tests do not claim official conformance.
27+
- Clarify that specification `v0.5.0` has no official Plan 0.3. Its syntax-only
28+
Frontend 0.3 over Plan 0.1/0.2 is separate from Python schema extensions.
29+
Legacy acceptance and Python extension tests do not claim official conformance.
1830
See [compatibility and migration](docs/transformations.md#contract-ownership-and-legacy-compatibility).
1931

2032
## 0.8.1 - 2026-09-10

‎docs/transformations.md‎

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ they are never treated as arbitrary SQL.
3434

3535
Published OpenStatSpec `v0.5.0` defines Transformation Plan 0.1/0.2, not
3636
Plan 0.3. Its optional SPSS Frontend 0.3 is a syntax-only expansion emitting
37-
Plan 0.1/0.2; this adapter does **not** implement that frontend yet.
37+
Plan 0.1/0.2, implemented through the explicit official request boundary below.
3838

3939
Explicit `create_variable` / `delete_variable` operations (including SPSS
4040
`STRING` / `DELETE VARIABLES`) belong to a **Python extension**, not official
@@ -46,7 +46,7 @@ OpenStatSpec conformance. New schema-changing compilations emit:
4646
The exported names `TRANSFORMATION_PLAN_SCHEMA_CHANGE_CONTRACT` and
4747
`SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT` remain unchanged; their values now use
4848
these Python-owned identifiers. Plans without these explicit schema operations
49-
retain their existing Plan 0.1/0.2 selection and frontend identifier. Official
49+
retain their existing Plan 0.1/0.2 selection and default frontend identifier. Official
5050
Plan 0.1/0.2 still reject explicit create/delete operations.
5151

5252
The loader and executor continue to accept the old Python plan identifier
@@ -64,6 +64,78 @@ when its operations are identical. An intentional change of a saved plan's
6464
contract likewise creates a new artifact with a new hash, not an audit migration.
6565
Older adapter versions cannot load the new IDs.
6666

67+
## Official Frontend 0.3
68+
69+
Use `openstatspec.compile_spss_request(request)` for the official JSON request
70+
boundary. It accepts **only** `openstatspec-spss-syntax-frontend-v0.3`, with
71+
exact required fields `contract`, `input_alias`, `input_schema`, and
72+
`source_text`. Unknown fields, missing fields, wrong types, noncanonical or
73+
nonfinite typed codes, ambiguous variable names, and mismatched label types
74+
fail closed. Request-shape failures report `invalid_spss_request`; source
75+
failures retain the normative diagnostics. It returns no partial compilation.
76+
77+
```python
78+
compilation = openstatspec.compile_spss_request({
79+
"contract": "openstatspec-spss-syntax-frontend-v0.3",
80+
"input_alias": "parent",
81+
"input_schema": {"variables": [
82+
{"name": "age", "storage_kind": "numeric"},
83+
]},
84+
"source_text": "COMMENT finite ages. RECODE age (LOWEST THRU 17 = 0).",
85+
})
86+
```
87+
88+
The ordered request dictionary accepts only `name`, `storage_kind`,
89+
`variable_label`, ordered typed `value_labels`, `format_family`, `width`,
90+
`decimals`, and `measurement_level`. In particular, `width`/`decimals` are
91+
request field names, not `format_width`/`format_decimals`. Descriptive input
92+
format metadata is preserved, including partial metadata and string formats;
93+
`FORMATS` operations still accept only bounded numeric F formats. Physical
94+
identifiers and Python `declared_string_width` are not request fields.
95+
96+
The same parser/binder implements command-boundary star and `COMMENT`
97+
comments, non-nested block comments, dictionary-order inclusive `TO` ranges,
98+
grouped recodes/labels/formats/levels, finite `LOWEST`/`HIGHEST` RECODE bounds,
99+
`NE`/`<>`/`~=`, `NOT`, and `ADD VALUE LABELS`. `TO` cannot generate `INTO`
100+
targets. Additive labels update an existing typed code at its ordinal and
101+
append new codes in source order, using the preceding label state. Numeric
102+
zero has positive-zero identity; string codes retain exact contents.
103+
Comments remain in the LF-normalized source hash but emit no operation;
104+
comment-only input is invalid. Python `STRING` and `DELETE VARIABLES` are
105+
rejected under the official selector.
106+
107+
**Explicit implementation decision:** precedence is standard SPSS order,
108+
comparisons → `NOT` → `AND` → `OR` (tightest first). Thus `NOT a = 1 AND
109+
b = 1` means `(NOT (a = 1)) AND b = 1`, not negation of the conjunction.
110+
Parentheses override precedence. Comparison complements and De Morgan lowering
111+
preserve SQL UNKNOWN, and maximal same-operator nodes flatten in source order.
112+
This decision has adapter regression tests; normative fixtures are unchanged.
113+
114+
Programs using only Plan 0.1 operations retain their exact Plan 0.1 object;
115+
any Plan 0.2-only operation selects Plan 0.2. Both source and complete canonical
116+
plan hashes remain independent. Official compilations record the 0.3 frontend
117+
identifier regardless of output-plan version.
118+
119+
For typed-schema or live-database callers, explicitly pass
120+
`frontend_contract="openstatspec-spss-syntax-frontend-v0.3"` to
121+
`compile_spss_syntax` or `apply_spss_in_place`. The latter compiles against the
122+
live schema within the existing transaction and records official frontend and
123+
source/plan provenance. JSON callers should use `compile_spss_request` rather
124+
than building a typed schema from untrusted fields. Omitting the selector
125+
retains all old support, rejection behavior, and Python extension selection;
126+
the existing CLI remains on that compatibility path.
127+
128+
Evidence: `tests/test_frontend_v03.py` runs the 35 declared and all 90 effective
129+
cases from specification commit
130+
`864e84479f554b8ee250ffed44c4dfb963750d4a`, applying only the published inherited
131+
contract overrides and two comment supersessions. It checks exact Plan 0.1/0.2
132+
objects/hashes, source hashes, diagnostics, and declared output metadata.
133+
Local SQLite integration checks UNKNOWN, sequential data and metadata,
134+
identity, audit provenance, and absence of copy/history artifacts. Existing
135+
frontend, plan, and in-place suites remain in the gate. This is not new service
136+
execution evidence: MySQL/MariaDB/Dolt provisioning restrictions and caller-owned
137+
Dolt commits are unchanged.
138+
67139
## Install the audit schema
68140

69141
Install the compact audit relation once before the first apply:

‎src/openstatspec/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
remove_derived_physical_relation, retire_derived, validate, validate_derived,
1111
)
1212
from .core import CapabilityDeclaration, LossReport, UnsupportedOperationError
13-
from .frontends.spss import SpssFrontendCompilation, compile_spss_syntax
13+
from .frontends.spss import SpssFrontendCompilation, compile_spss_syntax, compile_spss_request
1414
from .sql import DoltConformanceSource
1515
from .sql.workflow import TransformationError
1616
from .transform import (
@@ -37,7 +37,7 @@
3737
"TransformationPlan", "TypedValue", "ValueLabel",
3838
"VariableDefinition", "VariableSchema", "transformation_plan_from_dict",
3939
"apply_spss_in_place", "apply_transformation_plan_in_place",
40-
"compile_spss_syntax", "install_in_place_transformation_schema",
40+
"compile_spss_syntax", "compile_spss_request", "install_in_place_transformation_schema",
4141
"UnsupportedOperationError", "capabilities", "capability_matrix",
4242
"derive_sql_dataset", "dolt_state_snapshot", "execute_sql_transformation", "export_sav", "get_dataset",
4343
"import_sav", "initialize_catalog", "inspect", "list_datasets",

‎src/openstatspec/api.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ def derive_sql_dataset(*, database_url: Any, **options: Any) -> Mapping[str, Any
226226

227227
def apply_spss_in_place(
228228
*, database_url: Any, dataset_id: str, source_text: str,
229-
actor: str, expected_branch: str | None = None,
229+
actor: str, frontend_contract: str | None = None,
230+
expected_branch: str | None = None,
230231
expected_head: str | None = None,
231232
dolt_conformance_source: DoltConformanceSource | None = None,
232233
) -> Mapping[str, Any]:
@@ -239,6 +240,7 @@ def apply_spss_in_place(
239240
dataset_id=dataset_id,
240241
source_text=source_text,
241242
actor=actor,
243+
frontend_contract=frontend_contract,
242244
expected_branch=expected_branch,
243245
expected_head=expected_head,
244246
dolt_conformance_source=dolt_conformance_source,

‎src/openstatspec/frontends/spss/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
from .binding import bind_spss_syntax
44
from .compiler import (
55
SPSS_FRONTEND_CONTRACT,
6+
SPSS_FRONTEND_V03_CONTRACT,
67
SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT,
78
SpssFrontendCompilation,
89
compile_spss_syntax,
10+
compile_spss_request,
911
)
1012
from .syntax import (
1113
SpssSyntaxProgram,
@@ -18,11 +20,13 @@
1820

1921
__all__ = [
2022
"SPSS_FRONTEND_CONTRACT",
23+
"SPSS_FRONTEND_V03_CONTRACT",
2124
"SPSS_FRONTEND_SCHEMA_CHANGE_CONTRACT",
2225
"SpssFrontendCompilation",
2326
"SpssSyntaxProgram",
2427
"bind_spss_syntax",
2528
"compile_spss_syntax",
29+
"compile_spss_request",
2630
"normalize_spss_source",
2731
"parse_spss_syntax",
2832
"spss_source_hash",

‎src/openstatspec/frontends/spss/binding.py‎

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from .syntax import (
2525
BooleanSyntax, ComparisonSyntax, ComputeCommandSyntax, ExecuteCommandSyntax,
2626
DeleteVariablesCommandSyntax, FormatsCommandSyntax, IfCommandSyntax,
27-
OperandSyntax, PredicateSyntax,
27+
NotSyntax, OperandSyntax, PredicateSyntax, Token, VariableRangeSyntax,
2828
RecodeCommandSyntax, RecodeMatchSyntax, RecodeResultSyntax,
2929
SpssSyntaxProgram, SyntaxLiteral, ValueLabelsCommandSyntax,
3030
StringCommandSyntax, VariableLabelsCommandSyntax, VariableLevelCommandSyntax,
@@ -74,9 +74,16 @@ def _bind_operand(
7474

7575

7676
def _bind_predicate(
77-
syntax: PredicateSyntax, variables: list[VariableDefinition],
77+
syntax: PredicateSyntax, variables: list[VariableDefinition], *, negated: bool = False,
7878
) -> PredicateExpression:
79+
if isinstance(syntax, NotSyntax):
80+
return _bind_predicate(syntax.operand, variables, negated=not negated)
7981
if isinstance(syntax, ComparisonSyntax):
82+
if syntax.operator == "ne" or (negated and syntax.operator == "="):
83+
expanded = BooleanSyntax("or", tuple(
84+
replace(syntax, operator=operator) for operator in ("<", ">")
85+
), syntax.span)
86+
return _bind_predicate(expanded, variables, negated=negated and syntax.operator == "ne")
8087
left, left_type = _bind_operand(syntax.left, variables)
8188
right, right_type = _bind_operand(syntax.right, variables)
8289
if left_type != right_type:
@@ -91,19 +98,21 @@ def _bind_predicate(
9198
"Ordered comparisons require numeric operands.",
9299
span=syntax.span, operator=syntax.operator,
93100
)
94-
return ComparisonExpression(left, syntax.operator, right)
101+
operator = ({"=": "=", "<": ">=", "<=": ">", ">": "<=", ">=": "<"}[syntax.operator] if negated else syntax.operator)
102+
return ComparisonExpression(left, operator, right)
95103
assert isinstance(syntax, BooleanSyntax)
104+
operator = ("or" if syntax.operator == "and" else "and") if negated else syntax.operator
96105
operands: list[PredicateExpression] = []
97106
for operand in syntax.operands:
98-
bound = _bind_predicate(operand, variables)
107+
bound = _bind_predicate(operand, variables, negated=negated)
99108
if (
100109
isinstance(bound, BooleanExpression)
101-
and bound.operator == syntax.operator
110+
and bound.operator == operator
102111
):
103112
operands.extend(bound.operands)
104113
else:
105114
operands.append(bound)
106-
return BooleanExpression(syntax.operator, tuple(operands))
115+
return BooleanExpression(operator, tuple(operands))
107116

108117

109118
def _assignment(
@@ -151,7 +160,7 @@ def _assignment(
151160

152161

153162
def _match(
154-
syntax: RecodeMatchSyntax, source: VariableDefinition,
163+
syntax: RecodeMatchSyntax, source: VariableDefinition, *, official_v03: bool = False,
155164
) -> RecodeMatch:
156165
expected = _expected_type(source.storage_kind)
157166
if syntax.kind == "system_missing":
@@ -172,7 +181,7 @@ def _match(
172181
)
173182
if lower.number() > upper.number():
174183
raise frontend_error(
175-
"invalid_numeric_range",
184+
"invalid_variable_range" if official_v03 else "invalid_numeric_range",
176185
"THRU lower endpoint exceeds its upper endpoint.",
177186
span=syntax.span, variable=source.name,
178187
)
@@ -252,10 +261,12 @@ def _validate_recode_string_width(
252261

253262

254263
def _bind_recode(
255-
command: RecodeCommandSyntax, variables: list[VariableDefinition],
264+
command: RecodeCommandSyntax, variables: list[VariableDefinition], *, official_v03: bool = False,
256265
) -> tuple[list[RecodeOperation], list[SourceSpan]]:
257266
sources = [_resolve(variables, token.text, token.span)[1] for token in command.sources]
258267
targets = command.targets
268+
if targets is not None and len(targets) != len(sources):
269+
raise frontend_error("spss_syntax_error", "RECODE INTO requires one target per source.", span=command.span)
259270
target_mode: Literal["create", "replace"] = "create" if targets is not None else "replace"
260271
target_names = (
261272
[token.text for token in targets] if targets is not None
@@ -292,7 +303,7 @@ def _bind_recode(
292303
if clause.match.kind == "else":
293304
else_result = result
294305
continue
295-
rules.append(RecodeRule(_match(clause.match, source), result))
306+
rules.append(RecodeRule(_match(clause.match, source, official_v03=official_v03), result))
296307
unmatched = else_result or RecodeResult(
297308
"system_missing" if target_mode == "create" else "copy"
298309
)
@@ -332,6 +343,28 @@ def _bind_recode(
332343
# replace intentionally preserves the existing variable metadata. A later
333344
# VALUE LABELS command replaces value labels explicitly.
334345
return operations, spans
346+
347+
348+
def _expand_variables(
349+
tokens: tuple[Token | VariableRangeSyntax, ...], variables: list[VariableDefinition],
350+
) -> tuple[Token, ...]:
351+
expanded: list[Token] = []
352+
for token in tokens:
353+
if isinstance(token, VariableRangeSyntax):
354+
first, _ = _resolve(variables, token.first.text, token.first.span)
355+
last = first
356+
for endpoint in (token.last, *token.continuations):
357+
next_index, _ = _resolve(variables, endpoint.text, endpoint.span)
358+
if last > next_index:
359+
raise frontend_error("invalid_variable_range", "TO endpoints are reversed in dictionary order.", span=token.span)
360+
last = next_index
361+
expanded.extend(Token("identifier", v.name, v.name, token.span) for v in variables[first:last + 1])
362+
else:
363+
_resolve(variables, token.text, token.span)
364+
expanded.append(token)
365+
return tuple(expanded)
366+
367+
335368
def bind_spss_syntax(
336369
program: SpssSyntaxProgram, schema: VariableSchema, *, input_alias: str = "parent",
337370
) -> BoundTransformation:
@@ -345,6 +378,22 @@ def bind_spss_syntax(
345378
operations: list[PlanOperation] = []
346379
spans: list[SourceSpan] = []
347380
for command in program.commands:
381+
if program.official_v03:
382+
if isinstance(command, (StringCommandSyntax, DeleteVariablesCommandSyntax)):
383+
raise frontend_error("unsupported_spss_command", "Python schema commands are outside official Frontend 0.3.", span=command.span)
384+
if isinstance(command, RecodeCommandSyntax):
385+
command = replace(command, sources=_expand_variables(command.sources, variables))
386+
elif isinstance(command, (FormatsCommandSyntax, VariableLevelCommandSyntax, VariableLabelsCommandSyntax)):
387+
command = replace(command, assignments=tuple(
388+
replace(assignment, variable=token)
389+
for assignment in command.assignments
390+
for token in _expand_variables((assignment.variable,), variables)
391+
))
392+
elif isinstance(command, ValueLabelsCommandSyntax):
393+
command = replace(command, groups=tuple(
394+
replace(group, variables=_expand_variables(group.variables, variables))
395+
for group in command.groups
396+
))
348397
if isinstance(command, StringCommandSyntax):
349398
for variable_token in command.variables:
350399
if variable_token.text.startswith("__"):
@@ -383,7 +432,7 @@ def bind_spss_syntax(
383432
del variables[index]
384433
continue
385434
if isinstance(command, RecodeCommandSyntax):
386-
recodes, recode_spans = _bind_recode(command, variables)
435+
recodes, recode_spans = _bind_recode(command, variables, official_v03=program.official_v03)
387436
operations.extend(recodes)
388437
spans.extend(recode_spans)
389438
continue
@@ -515,6 +564,10 @@ def bind_spss_syntax(
515564
"VALUE LABELS contains duplicate canonical codes.",
516565
span=group.span, variable=variable.name,
517566
)
567+
if command.additive:
568+
merged = {label.value.canonical_key(): label for label in variable.value_labels}
569+
merged.update((label.value.canonical_key(), label) for label in labels)
570+
labels = tuple(merged.values())
518571
operation = ReplaceValueLabelsOperation(variable.name, labels)
519572
operations.append(operation)
520573
spans.append(group.span)

0 commit comments

Comments
 (0)